diff --git a/backend/backend/admin.py b/backend/backend/admin.py index 2d5ad6f..c6c325d 100644 --- a/backend/backend/admin.py +++ b/backend/backend/admin.py @@ -44,8 +44,9 @@ def _is_appointer(self, user): def get_team_filter(self, team_ids): return {} - def get_object_team_id(self, obj): - return None + def get_object_team_ids(self, obj): + """Team ids an object belongs to (a role may now belong to several).""" + return [] def has_module_permission(self, request): return request.user.is_superuser or self._is_appointer(request.user) @@ -61,9 +62,8 @@ def has_view_permission(self, request, obj=None): obj = self.get_object(request, obj) if obj is None: return False - return self.get_object_team_id(obj) in self._get_appointer_team_ids( - request.user - ) + appointer_team_ids = set(self._get_appointer_team_ids(request.user)) + return bool(set(self.get_object_team_ids(obj)) & appointer_team_ids) def has_add_permission(self, request): if request.user.is_superuser: @@ -101,6 +101,7 @@ class GroupAdmin(BaseGroupAdmin, ModelAdmin): list_filter_submit = True +@admin.register(Member) class MemberAdmin(BaseUserAdmin, ModelAdmin): """ Custom admin interface for the Member model. @@ -178,29 +179,34 @@ class TeamAdmin(ModelAdmin): @admin.register(Role) class RoleAdmin(AppointerTeamScopeMixin, ModelAdmin): - list_display = ("title_en", "title_sv", "team", "role_type", "archived") - list_filter = ("team", "role_type", "archived") + list_display = ("title_en", "title_sv", "display_teams", "role_type", "archived") + list_filter = ("teams", "role_type", "archived") search_fields = ( "title_en", "title_sv", "description_en", "description_sv", - "team__name_en", - "team__name_sv", + "teams__name_en", + "teams__name_sv", ) list_filter_submit = True + filter_horizontal = ("teams",) + + @admin.display(description=_("Teams")) + def display_teams(self, obj): + return ", ".join(obj.teams.values_list("name_en", flat=True)) def get_team_filter(self, team_ids): - return {"team_id__in": team_ids} + return {"teams__id__in": team_ids} - def get_object_team_id(self, obj): - return obj.team_id + def get_object_team_ids(self, obj): + return list(obj.teams.values_list("id", flat=True)) - def formfield_for_foreignkey(self, db_field, request, **kwargs): - if not request.user.is_superuser and db_field.name == "team": + def formfield_for_manytomany(self, db_field, request, **kwargs): + if not request.user.is_superuser and db_field.name == "teams": team_ids = self._get_appointer_team_ids(request.user) kwargs["queryset"] = Team.objects.filter(id__in=team_ids) - return super().formfield_for_foreignkey(db_field, request, **kwargs) + return super().formfield_for_manytomany(db_field, request, **kwargs) @admin.register(Position) @@ -213,21 +219,21 @@ class PositionAdmin(AppointerTeamScopeMixin, ModelAdmin): "term_end", "appointed", ) - list_filter = ("role__team", "recruitment_start", "term_from") + list_filter = ("role__teams", "recruitment_start", "term_from") search_fields = ("role__title_en", "role__title_sv", "comment_eng", "comment_sv") list_filter_submit = True date_hierarchy = "recruitment_start" def get_team_filter(self, team_ids): - return {"role__team_id__in": team_ids} + return {"role__teams__id__in": team_ids} - def get_object_team_id(self, obj): - return obj.role.team_id + def get_object_team_ids(self, obj): + return list(obj.role.teams.values_list("id", flat=True)) def formfield_for_foreignkey(self, db_field, request, **kwargs): if not request.user.is_superuser and db_field.name == "role": team_ids = self._get_appointer_team_ids(request.user) - kwargs["queryset"] = Role.objects.filter(team_id__in=team_ids) + kwargs["queryset"] = Role.objects.filter(teams__id__in=team_ids).distinct() return super().formfield_for_foreignkey(db_field, request, **kwargs) class ReferenceInline(admin.TabularInline): @@ -256,7 +262,7 @@ def _has_application_scope(self, request, obj=None): if obj is None: return True - return obj.position.role.team_id in team_ids + return obj.position.role.teams.filter(id__in=team_ids).exists() def has_view_permission(self, request, obj=None): return self._has_application_scope(request, obj) @@ -273,7 +279,7 @@ def has_add_permission(self, request, _obj=None): @admin.register(Application) class ApplicationAdmin(AppointerTeamScopeMixin, ModelAdmin): list_display = ("position", "member", "status", "decision_date") - list_filter = ("status", "position__role__team") + list_filter = ("status", "position__role__teams") search_fields = ( "position__role__title_en", "position__role__title_sv", @@ -324,22 +330,24 @@ def turn_down_application(self, request, object_id): return redirect(reverse("admin:backend_application_change", args=[object_id])) def get_team_filter(self, team_ids): - return {"position__role__team_id__in": team_ids} + return {"position__role__teams__id__in": team_ids} - def get_object_team_id(self, obj): - return obj.position.role.team_id + def get_object_team_ids(self, obj): + return list(obj.position.role.teams.values_list("id", flat=True)) def formfield_for_foreignkey(self, db_field, request, **kwargs): if not request.user.is_superuser and db_field.name == "position": team_ids = self._get_appointer_team_ids(request.user) - kwargs["queryset"] = Position.objects.filter(role__team_id__in=team_ids) + kwargs["queryset"] = Position.objects.filter( + role__teams__id__in=team_ids + ).distinct() return super().formfield_for_foreignkey(db_field, request, **kwargs) @admin.register(Appointment) class AppointmentAdmin(AppointerTeamScopeMixin, ModelAdmin): list_display = ("member", "position", "status", "appointed_date", "appointed_by") - list_filter = ("status", "position__role__team") + list_filter = ("status", "position__role__teams") search_fields = ( "member__name", "member__email", @@ -350,37 +358,39 @@ class AppointmentAdmin(AppointerTeamScopeMixin, ModelAdmin): date_hierarchy = "appointed_date" def get_team_filter(self, team_ids): - return {"position__role__team_id__in": team_ids} + return {"position__role__teams__id__in": team_ids} - def get_object_team_id(self, obj): - return obj.position.role.team_id + def get_object_team_ids(self, obj): + return list(obj.position.role.teams.values_list("id", flat=True)) def formfield_for_foreignkey(self, db_field, request, **kwargs): if not request.user.is_superuser and db_field.name == "position": team_ids = self._get_appointer_team_ids(request.user) - kwargs["queryset"] = Position.objects.filter(role__team_id__in=team_ids) + kwargs["queryset"] = Position.objects.filter( + role__teams__id__in=team_ids + ).distinct() return super().formfield_for_foreignkey(db_field, request, **kwargs) def has_add_permission(self, request): return False -class StudyProgramInline(admin.TabularInline): - model = StudyProgram - extra = 1 - @admin.register(Section) class SectionAdmin(admin.ModelAdmin): list_display = ("abbreviation", "section_en", "section_sv") search_fields = ("abbreviation", "section_en", "section_sv") - inlines = [StudyProgramInline] list_filter_submit = True @admin.register(StudyProgram) class StudyProgramAdmin(admin.ModelAdmin): - list_display = ("name_en", "name_sv", "section") - search_fields = ("name_en", "name_sv", "section__abbreviation") - list_filter = ("section",) + list_display = ("name_en", "name_sv", "degree", "display_sections") + search_fields = ("name_en", "name_sv", "sections__abbreviation") + list_filter = ("sections", "degree") list_filter_submit = True + filter_horizontal = ("sections",) + + @admin.display(description=_("Sections")) + def display_sections(self, obj): + return ", ".join(obj.sections.values_list("abbreviation", flat=True)) diff --git a/backend/backend/migrations/0010_studyprogram_degree.py b/backend/backend/migrations/0010_studyprogram_degree.py new file mode 100644 index 0000000..5e94596 --- /dev/null +++ b/backend/backend/migrations/0010_studyprogram_degree.py @@ -0,0 +1,18 @@ +# Generated by Django 4.1.7 on 2026-06-12 15:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('backend', '0009_add_email_verification_code'), + ] + + operations = [ + migrations.AddField( + model_name='studyprogram', + name='degree', + field=models.CharField(blank=True, choices=[('bsc', 'Bachelor of Science'), ('msc', 'Master of Science'), ('msceng', 'Master of Science in Engineering'), ('be', 'Bachelor of Engineering')], default='', help_text='The degree awarded by this study program', max_length=20, verbose_name='Degree'), + ), + ] diff --git a/backend/backend/migrations/0011_role_election_email.py b/backend/backend/migrations/0011_role_election_email.py new file mode 100644 index 0000000..916f8a7 --- /dev/null +++ b/backend/backend/migrations/0011_role_election_email.py @@ -0,0 +1,18 @@ +# Generated by Django 4.1.7 on 2026-06-12 16:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('backend', '0010_studyprogram_degree'), + ] + + operations = [ + migrations.AddField( + model_name='role', + name='election_email', + field=models.EmailField(blank=True, default='', help_text='The email address used for nominations/elections to this role', max_length=254, verbose_name='Election email address'), + ), + ] diff --git a/backend/backend/migrations/0012_remove_role_team_remove_studyprogram_section_and_more.py b/backend/backend/migrations/0012_remove_role_team_remove_studyprogram_section_and_more.py new file mode 100644 index 0000000..b7a76a3 --- /dev/null +++ b/backend/backend/migrations/0012_remove_role_team_remove_studyprogram_section_and_more.py @@ -0,0 +1,31 @@ +# Generated by Django 4.1.7 on 2026-06-12 16:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('backend', '0011_role_election_email'), + ] + + operations = [ + migrations.RemoveField( + model_name='role', + name='team', + ), + migrations.RemoveField( + model_name='studyprogram', + name='section', + ), + migrations.AddField( + model_name='role', + name='teams', + field=models.ManyToManyField(blank=True, help_text='The teams this role belongs to', related_name='roles', to='backend.team', verbose_name='Teams'), + ), + migrations.AddField( + model_name='studyprogram', + name='sections', + field=models.ManyToManyField(blank=True, help_text='The sections this study program belongs to', related_name='study_programs', to='backend.section', verbose_name='Sections'), + ), + ] diff --git a/backend/backend/models.py b/backend/backend/models.py index 2f0095e..0764a18 100644 --- a/backend/backend/models.py +++ b/backend/backend/models.py @@ -241,11 +241,11 @@ def get_appointer_team_ids(self, reference_date=None): return list( Team.objects.filter( - role__positions__appointments__member=self, - role__positions__appointments__status=Appointment.APPOINTED, - role__positions__term_from__lte=reference_date, - role__positions__term_end__gte=reference_date, - role__role_type__in=role_types, + roles__positions__appointments__member=self, + roles__positions__appointments__status=Appointment.APPOINTED, + roles__positions__term_from__lte=reference_date, + roles__positions__term_end__gte=reference_date, + roles__role_type__in=role_types, ) .values_list("id", flat=True) .distinct() @@ -453,13 +453,27 @@ class StudyProgram(models.Model): section (ForeignKey): A foreign key to the Section model, representing the section to which the study program belongs. name_en (CharField): The name of the study program in English. name_sv (CharField): The name of the study program in Swedish. + degree (CharField): The degree awarded by the study program (e.g. bachelor, master). """ - section = models.ForeignKey( + BSC = "bsc" + MSC = "msc" + MSCENG = "msceng" + BE = "be" + + DEGREE_CHOICES = ( + (BSC, _("Bachelor of Science")), + (MSC, _("Master of Science")), + (MSCENG, _("Master of Science in Engineering")), + (BE, _("Bachelor of Engineering")), + ) + + sections = models.ManyToManyField( "Section", related_name="study_programs", - on_delete=models.CASCADE, - blank=False, + verbose_name=_("Sections"), + help_text=_("The sections this study program belongs to"), + blank=True, ) name_en = models.CharField( @@ -475,6 +489,15 @@ class StudyProgram(models.Model): help_text=_("Enter the name of the section in Swedish"), ) + degree = models.CharField( + max_length=20, + choices=DEGREE_CHOICES, + verbose_name=_("Degree"), + help_text=_("The degree awarded by this study program"), + blank=True, + default="", + ) + def __str__(self): return self.name_en @@ -680,11 +703,12 @@ class Role(models.Model): (INVOLVED, _("Involved")), ) - team = models.ForeignKey( + teams = models.ManyToManyField( "Team", - related_name="role", - on_delete=models.CASCADE, - blank=False, + related_name="roles", + verbose_name=_("Teams"), + help_text=_("The teams this role belongs to"), + blank=True, ) role_type = models.CharField( @@ -776,6 +800,13 @@ def appointer_role_types(): help_text=_("The email address for the current position holder"), blank=False, ) + + election_email = models.EmailField( + verbose_name=_("Election email address"), + help_text=_("The email address used for nominations/elections to this role"), + blank=True, + default="", + ) # ------ Administrator settings ------ # panels = [MultiFieldPanel([ # FieldRowPanel([ @@ -796,7 +827,8 @@ def appointer_role_types(): # ])] def __str__(self): - return f"{self.title_en} ({self.team})" + team_names = ", ".join(self.teams.values_list("name_en", flat=True)) + return f"{self.title_en} ({team_names})" if team_names else self.title_en def _sync_staff_status(member, reference_date=None): diff --git a/backend/backend/serializers.py b/backend/backend/serializers.py index a4a84ae..ef10e2a 100644 --- a/backend/backend/serializers.py +++ b/backend/backend/serializers.py @@ -29,11 +29,11 @@ class Meta: class StudyProgramSerializer(ModelSerializer): - section = SectionSerializer(read_only=True) + sections = SectionSerializer(many=True, read_only=True) class Meta: model = StudyProgram - fields = ["id", "name_en", "name_sv", "section"] + fields = ["id", "name_en", "name_sv", "degree", "sections"] read_only_fields = ["id"] @@ -120,7 +120,11 @@ def validate(self, attrs): section = attrs.get("section_id") study_program = attrs.get("study_program") - if section and study_program and study_program.section_id != section.id: + if ( + section + and study_program + and not study_program.sections.filter(id=section.id).exists() + ): raise serializers.ValidationError( {"section_id": ["Section does not match selected program."]} ) @@ -166,14 +170,16 @@ def create(self, validated_data): class RoleDetailSerializer(ModelSerializer): team_name = serializers.SerializerMethodField() + team_names = serializers.SerializerMethodField() title = serializers.SerializerMethodField() description = serializers.SerializerMethodField() - team_logo = serializers.ImageField(source="team.logo", read_only=True) + team_logo = serializers.SerializerMethodField() class Meta: model = Role fields = [ "team_name", + "team_names", "team_logo", "title", "description", @@ -181,9 +187,31 @@ class Meta: "contact_email", ] + @staticmethod + def _primary_team(obj): + # A role can now belong to several teams; expose the first as the + # representative one for backwards compatibility with the frontend. + return obj.teams.first() + def get_team_name(self, obj): + team = self._primary_team(obj) + if team is None: + return None + lang = get_language_from_request(self.context.get("request")) + return team.name_sv if lang == "sv" else team.name_en + + def get_team_names(self, obj): lang = get_language_from_request(self.context.get("request")) - return obj.team.name_sv if lang == "sv" else obj.team.name_en + field = "name_sv" if lang == "sv" else "name_en" + return list(obj.teams.values_list(field, flat=True)) + + def get_team_logo(self, obj): + team = self._primary_team(obj) + if team is None or not team.logo: + return None + request = self.context.get("request") + url = team.logo.url + return request.build_absolute_uri(url) if request else url def get_title(self, obj): lang = get_language_from_request(self.context.get("request")) diff --git a/backend/backend/views.py b/backend/backend/views.py index ce49dfe..ebccf95 100644 --- a/backend/backend/views.py +++ b/backend/backend/views.py @@ -572,14 +572,18 @@ class PositionViewSet(ReadOnlyModelViewSet): def list(self, request): """Return both open positions and user's positions""" if request.user.is_authenticated: - my_positions = Position.objects.for_member(request.user).select_related( - "role", "role__team" + my_positions = ( + Position.objects.for_member(request.user) + .select_related("role") + .prefetch_related("role__teams") ) else: my_positions = Position.objects.none() - open_positions = Position.objects.open_positions().select_related( - "role", "role__team" + open_positions = ( + Position.objects.open_positions() + .select_related("role") + .prefetch_related("role__teams") ) return Response( @@ -608,8 +612,10 @@ class OpenPositionsAPIView(APIView): permission_classes = [AllowAny] def get(self, request): - open_positions = Position.objects.open_positions().select_related( - "role", "role__team" + open_positions = ( + Position.objects.open_positions() + .select_related("role") + .prefetch_related("role__teams") ) serializer = PositionSerializer( open_positions, many=True, context={"request": request} diff --git a/migration/migrate.sql b/migration/migrate.sql new file mode 100644 index 0000000..1c50394 --- /dev/null +++ b/migration/migrate.sql @@ -0,0 +1,262 @@ +-- ============================================================================ +-- migrate.sql — OLD (Wagtail "apply") -> NEW ("backend" app) data migration +-- ---------------------------------------------------------------------------- +-- Reads the OLD tables directly from a `legacy` schema (the original dump, +-- restored and renamed from `public` to `legacy`) and writes them into the new +-- Django `backend_*` tables. Runs as a SINGLE TRANSACTION: either everything +-- succeeds or nothing changes. +-- +-- PRECONDITIONS +-- * Target DB has the new schema applied (python manage.py migrate). +-- * Target backend_* domain tables are EMPTY (fresh migrate); aborts otherwise. +-- * The old data is present in a `legacy` schema with its ORIGINAL table names +-- (legacy.involvement_*, legacy.members_*, legacy.auth_group, ...). +-- +-- STAGING THE `legacy` SCHEMA (run once before this script): +-- 1. Restore the old dump into a scratch database (create role "moore" first +-- if the dump's OWNER lines error out): +-- createdb apply_legacy && psql -d apply_legacy -f apply-dump.sql +-- 2. Rename its schema so the tables live under legacy.*: +-- psql -d apply_legacy -c 'ALTER SCHEMA public RENAME TO legacy' +-- 3. Copy the needed tables into the target DB's legacy schema: +-- psql -d -c 'CREATE SCHEMA legacy' +-- pg_dump -d apply_legacy --section=pre-data --section=data --no-owner \ +-- -t 'legacy.involvement_*' -t 'legacy.members_*' -t legacy.auth_group \ +-- | psql -d +-- Then run this file, and `DROP SCHEMA legacy CASCADE` when done. +-- +-- KEY TRANSFORMATIONS +-- * members_member.id (integer) -> backend_member.id (UUID); a temp map remaps +-- every member FK. +-- * All other tables keep their original integer IDs (new PKs are bigint). +-- * role_type 'engaged' -> 'involved'. +-- * Role<->Team and StudyProgram<->Section stay many-to-many (all links kept). +-- * Members with empty/NULL ssn get a synthetic unique placeholder ('MIG'+id). +-- * mandatehistory -> Appointment (status 'appointed', date = position.term_to). +-- ============================================================================ + +BEGIN; + +-- Fail fast unless the destination domain tables are empty ------------------- +DO $$ +DECLARE n bigint; +BEGIN + SELECT + (SELECT count(*) FROM backend_member) + (SELECT count(*) FROM backend_team) + + (SELECT count(*) FROM backend_section) + (SELECT count(*) FROM backend_studyprogram) + + (SELECT count(*) FROM backend_role) + (SELECT count(*) FROM backend_position) + + (SELECT count(*) FROM backend_application) + (SELECT count(*) FROM backend_reference) + + (SELECT count(*) FROM backend_appointment) + INTO n; + IF n <> 0 THEN + RAISE EXCEPTION 'Target domain tables are not empty (% rows). Refusing to migrate into a non-empty schema.', n; + END IF; +END $$; + +-- --------------------------------------------------------------------------- +-- 0. Member integer-id -> UUID mapping +-- --------------------------------------------------------------------------- +CREATE TEMP TABLE member_id_map ( + old_id integer PRIMARY KEY, + new_id uuid NOT NULL DEFAULT gen_random_uuid() +) ON COMMIT DROP; +INSERT INTO member_id_map (old_id) +SELECT id FROM legacy.members_member; + +-- --------------------------------------------------------------------------- +-- 1. Section (legacy.members_section -> backend_section) +-- --------------------------------------------------------------------------- +INSERT INTO backend_section (id, abbreviation, section_en, section_sv) +SELECT id, + left(coalesce(abbreviation, ''), 20), + coalesce(name_en, ''), + coalesce(name_sv, '') +FROM legacy.members_section; + +-- --------------------------------------------------------------------------- +-- 2. StudyProgram (legacy.members_studyprogram -> backend_studyprogram) +-- Sections are a many-to-many (step 2b); degree is carried across. +-- --------------------------------------------------------------------------- +INSERT INTO backend_studyprogram (id, name_en, name_sv, degree) +SELECT id, + coalesce(name_en, ''), + coalesce(name_sv, ''), + coalesce(degree, '') +FROM legacy.members_studyprogram; + +-- 2b. StudyProgram <-> Section m2m: ALL links from the old junction table. +INSERT INTO backend_studyprogram_sections (studyprogram_id, section_id) +SELECT studyprogram_id, section_id +FROM legacy.members_section_studies; + +-- --------------------------------------------------------------------------- +-- 3. Team (legacy.involvement_team -> backend_team) +-- NOTE: old logo (wagtail image FK) has no destination -> logo = ''. +-- --------------------------------------------------------------------------- +INSERT INTO backend_team (id, name_en, name_sv, logo, desc_en, desc_sv) +SELECT id, + coalesce(name_en, ''), + coalesce(name_sv, ''), + '', + coalesce(description_en, ''), + coalesce(description_sv, '') +FROM legacy.involvement_team; + +-- --------------------------------------------------------------------------- +-- 4. Role (legacy.involvement_role -> backend_role) +-- Teams are a many-to-many (step 4b). role_type 'engaged' -> 'involved'. +-- election_email carried across; dropped: group_id, phone_number. +-- --------------------------------------------------------------------------- +INSERT INTO backend_role + (id, role_type, archived, title_en, title_sv, description_en, description_sv, + contact_email, election_email, role_description_url) +SELECT id, + CASE WHEN role_type = 'engaged' THEN 'involved' ELSE role_type END, + coalesce(archived, false), + coalesce(name_en, ''), + coalesce(name_sv, ''), + coalesce(description_en, ''), + coalesce(description_sv, ''), + coalesce(contact_email, ''), + coalesce(election_email, ''), + '' +FROM legacy.involvement_role; + +-- 4b. Role <-> Team m2m: ALL links from the old junction table. +INSERT INTO backend_role_teams (role_id, team_id) +SELECT role_id, team_id +FROM legacy.involvement_role_teams; + +-- --------------------------------------------------------------------------- +-- 5. Position (legacy.involvement_position -> backend_position) +-- --------------------------------------------------------------------------- +INSERT INTO backend_position + (id, recruitment_start, recruitment_end, appointed, term_from, term_end, + comment_eng, comment_sv, role_id) +SELECT id, + recruitment_start, + recruitment_end, + coalesce(appointments, 1), + term_from, + term_to, + coalesce(comment_en, ''), + coalesce(comment_sv, ''), + role_id +FROM legacy.involvement_position; + +-- --------------------------------------------------------------------------- +-- 6. Member (legacy.members_member -> backend_member) [integer id -> UUID] +-- ssn (= old person_nr); empty/NULL -> synthetic unique 'MIG##########'. +-- verified_email = TRUE: pre-existing accounts that predate the +-- email-verification feature. +-- Dropped: username, date_joined, status_changed, section_id (member's +-- section is derived via study_program in the new schema). +-- --------------------------------------------------------------------------- +INSERT INTO backend_member + (id, password, last_login, unicore_id, email, verified_email, phone_number, + is_superuser, is_staff, is_active, name, ssn, registration_year, status, + study_program_id, email_verification_code, email_verification_code_expires_at, + email_verification_attempts, email_verification_sent_at) +SELECT map.new_id, + coalesce(m.password, ''), + m.last_login, + m.unicore_id, + coalesce(m.email, ''), + true, + coalesce(m.phone_number, ''), + coalesce(m.is_superuser, false), + coalesce(m.is_staff, false), + coalesce(m.is_active, true), + coalesce(m.name, ''), + CASE WHEN m.person_nr IS NULL OR btrim(m.person_nr) = '' + THEN 'MIG' || lpad(m.id::text, 10, '0') + ELSE m.person_nr END, + coalesce(m.registration_year, ''), + coalesce(m.status, 'unknown'), + m.study_id, + NULL, NULL, 0, NULL +FROM legacy.members_member m +JOIN member_id_map map ON map.old_id = m.id; + +-- --------------------------------------------------------------------------- +-- 6b. Permission groups + member group memberships +-- auth_group: the named groups / committees (ids preserved). +-- backend_member_groups: member<->group links (member_id remapped to UUID). +-- Group permissions are NOT migrated (old content-type ids aren't portable); +-- the groups arrive permission-less. Assumes auth_group is empty. +-- --------------------------------------------------------------------------- +INSERT INTO auth_group (id, name) +SELECT id, name FROM legacy.auth_group; + +INSERT INTO backend_member_groups (member_id, group_id) +SELECT map.new_id, mg.group_id +FROM legacy.members_member_groups mg +JOIN member_id_map map ON map.old_id = mg.member_id; + +-- --------------------------------------------------------------------------- +-- 7. Application (legacy.involvement_application -> backend_application) +-- member_id remapped to UUID; decision_date = old rejection_date. +-- Dropped: removed flag. +-- --------------------------------------------------------------------------- +INSERT INTO backend_application + (id, status, cover_letter, qualifications, gdpr, decision_date, member_id, position_id) +SELECT a.id, + a.status, + coalesce(a.cover_letter, ''), + coalesce(a.qualifications, ''), + coalesce(a.gdpr, false), + a.rejection_date, + map.new_id, + a.position_id +FROM legacy.involvement_application a +JOIN member_id_map map ON map.old_id = a.applicant_id; + +-- --------------------------------------------------------------------------- +-- 8. Reference (legacy.involvement_reference -> backend_reference) +-- old "position" -> title ; old phone_number -> phone_num. +-- --------------------------------------------------------------------------- +INSERT INTO backend_reference (id, name, phone_num, title, email, comment, application_id) +SELECT id, + coalesce(name, ''), + coalesce(phone_number, ''), + coalesce("position", ''), + coalesce(email, ''), + coalesce(comment, ''), + application_id +FROM legacy.involvement_reference; + +-- --------------------------------------------------------------------------- +-- 9. Appointment (legacy.involvement_mandatehistory -> backend_appointment) +-- One appointment per old mandate row; status 'appointed'; +-- appointed_date = position.term_to; appointed_by NULL. +-- --------------------------------------------------------------------------- +INSERT INTO backend_appointment + (appointed_date, status, resignation_date, resignation_reason, notes, + appointed_by_id, member_id, position_id) +SELECT coalesce(p.term_from, CURRENT_DATE), + 'appointed', + NULL, NULL, NULL, + NULL, + map.new_id, + mh.position_id +FROM legacy.involvement_mandatehistory mh +JOIN member_id_map map ON map.old_id = mh.applicant_id +LEFT JOIN legacy.involvement_position p ON p.id = mh.position_id; + +-- --------------------------------------------------------------------------- +-- 10. Reset sequences so future inserts don't collide with migrated ids +-- --------------------------------------------------------------------------- +SELECT setval(pg_get_serial_sequence('backend_section', 'id'), coalesce((SELECT max(id) FROM backend_section), 1), true); +SELECT setval(pg_get_serial_sequence('backend_studyprogram', 'id'), coalesce((SELECT max(id) FROM backend_studyprogram), 1), true); +SELECT setval(pg_get_serial_sequence('backend_team', 'id'), coalesce((SELECT max(id) FROM backend_team), 1), true); +SELECT setval(pg_get_serial_sequence('backend_role', 'id'), coalesce((SELECT max(id) FROM backend_role), 1), true); +SELECT setval(pg_get_serial_sequence('backend_position', 'id'), coalesce((SELECT max(id) FROM backend_position), 1), true); +SELECT setval(pg_get_serial_sequence('backend_application', 'id'), coalesce((SELECT max(id) FROM backend_application), 1), true); +SELECT setval(pg_get_serial_sequence('backend_reference', 'id'), coalesce((SELECT max(id) FROM backend_reference), 1), true); +SELECT setval(pg_get_serial_sequence('backend_appointment', 'id'), coalesce((SELECT max(id) FROM backend_appointment), 1), true); +SELECT setval(pg_get_serial_sequence('auth_group', 'id'), coalesce((SELECT max(id) FROM auth_group), 1), true); +SELECT setval(pg_get_serial_sequence('backend_member_groups','id'), coalesce((SELECT max(id) FROM backend_member_groups), 1), true); +SELECT setval(pg_get_serial_sequence('backend_role_teams', 'id'), coalesce((SELECT max(id) FROM backend_role_teams), 1), true); +SELECT setval(pg_get_serial_sequence('backend_studyprogram_sections','id'), coalesce((SELECT max(id) FROM backend_studyprogram_sections), 1), true); + +COMMIT;