diff --git a/kompassi/events/kotaeexpo2026/__init__.py b/kompassi/events/kotaeexpo2026/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/kompassi/events/kotaeexpo2026/forms.py b/kompassi/events/kotaeexpo2026/forms.py
new file mode 100644
index 000000000..c7c078271
--- /dev/null
+++ b/kompassi/events/kotaeexpo2026/forms.py
@@ -0,0 +1,242 @@
+from crispy_forms.layout import Fieldset, Layout
+from django import forms
+from django.db.models import Q
+
+from kompassi.core.utils import horizontal_form_helper, indented_without_label
+from kompassi.labour.forms import AlternativeFormMixin, SignupForm
+from kompassi.labour.models import JobCategory, Signup
+
+from .models import SignupExtra
+
+
+class SignupExtraForm(forms.ModelForm):
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.helper = horizontal_form_helper()
+ self.helper.form_tag = False
+ self.helper.layout = Layout(
+ "shift_type",
+ "total_work",
+ indented_without_label("night_shift"),
+ indented_without_label("overseer"),
+ "work_days",
+ Fieldset(
+ "Työtodistus",
+ indented_without_label("want_certificate"),
+ ),
+ Fieldset(
+ "Millä kielellä olet valmis palvelemaan asiakkaita?",
+ "known_language",
+ "known_language_other",
+ ),
+ Fieldset(
+ "Lisätiedot",
+ "special_diet",
+ "special_diet_other",
+ "accommodation",
+ "prior_experience",
+ "shift_wishes",
+ "free_text",
+ ),
+ )
+
+ class Meta:
+ model = SignupExtra
+ fields = (
+ "shift_type",
+ "total_work",
+ "night_shift",
+ "overseer",
+ "work_days",
+ "want_certificate",
+ "known_language",
+ "known_language_other",
+ "special_diet",
+ "special_diet_other",
+ "accommodation",
+ "prior_experience",
+ "shift_wishes",
+ "free_text",
+ )
+
+ widgets = dict(
+ known_language=forms.CheckboxSelectMultiple,
+ special_diet=forms.CheckboxSelectMultiple,
+ accommodation=forms.CheckboxSelectMultiple,
+ work_days=forms.CheckboxSelectMultiple,
+ )
+
+
+class OrganizerSignupForm(forms.ModelForm, AlternativeFormMixin):
+ def __init__(self, *args, **kwargs):
+ kwargs.pop("event")
+ admin = kwargs.pop("admin")
+
+ if admin:
+ raise AssertionError("must not be admin")
+
+ super().__init__(*args, **kwargs)
+
+ self.helper = horizontal_form_helper()
+ self.helper.form_tag = False
+ self.helper.layout = Layout(
+ Fieldset(
+ "Tehtävän tiedot",
+ "job_title",
+ ),
+ )
+
+ self.fields["job_title"].help_text = "Mikä on vastuualueesi? Printataan badgeen."
+ self.fields["job_title"].required = True
+
+ class Meta:
+ model = Signup
+ fields = ("job_title",)
+
+ widgets = dict(
+ job_categories=forms.CheckboxSelectMultiple,
+ )
+
+ def get_excluded_m2m_field_defaults(self):
+ return dict(job_categories=JobCategory.objects.filter(event__slug="kotaeexpo2026", name="Vastaava"))
+
+ def get_excluded_field_defaults(self):
+ return dict(
+ total_work="yli10h",
+ )
+
+
+class OrganizerSignupExtraForm(forms.ModelForm, AlternativeFormMixin):
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.helper = horizontal_form_helper()
+ self.helper.form_tag = False
+ self.helper.layout = Layout(
+ Fieldset(
+ "Lisätiedot",
+ "special_diet",
+ "special_diet_other",
+ "accommodation",
+ "email_alias",
+ ),
+ )
+
+ class Meta:
+ model = SignupExtra
+ fields = (
+ "special_diet",
+ "special_diet_other",
+ "accommodation",
+ "email_alias",
+ )
+
+ widgets = dict(
+ special_diet=forms.CheckboxSelectMultiple,
+ accommodation=forms.CheckboxSelectMultiple,
+ )
+
+ def get_excluded_field_defaults(self):
+ return dict(
+ shift_type="kaikkikay",
+ total_work="yli10h",
+ night_shift=False,
+ overseer=False,
+ want_certificate=False,
+ prior_experience="",
+ free_text="Syötetty käyttäen coniitin ilmoittautumislomaketta",
+ )
+
+
+class SpecialistSignupForm(SignupForm, AlternativeFormMixin):
+ def get_job_categories_query(self, event, admin=False):
+ if admin:
+ raise AssertionError("must not be admin")
+
+ return Q(event__slug="kotaeexpo2026", public=False) & ~Q(slug="vastaava")
+
+ def get_excluded_field_defaults(self):
+ return dict(
+ notes="Syötetty käyttäen erikoistehtävien ilmoittautumislomaketta",
+ )
+
+
+class SpecialistSignupExtraForm(forms.ModelForm, AlternativeFormMixin):
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.helper = horizontal_form_helper()
+ self.helper.form_tag = False
+ self.helper.layout = Layout(
+ "shift_type",
+ "total_work",
+ indented_without_label("night_shift"),
+ indented_without_label("overseer"),
+ "work_days",
+ Fieldset(
+ "Työtodistus",
+ indented_without_label("want_certificate"),
+ ),
+ Fieldset(
+ "Millä kielellä olet valmis palvelemaan asiakkaita?",
+ "known_language",
+ "known_language_other",
+ ),
+ Fieldset(
+ "Lisätiedot",
+ "special_diet",
+ "special_diet_other",
+ "accommodation",
+ "prior_experience",
+ "shift_wishes",
+ "free_text",
+ ),
+ )
+
+ class Meta:
+ model = SignupExtra
+ fields = (
+ "shift_type",
+ "total_work",
+ "night_shift",
+ "overseer",
+ "work_days",
+ "want_certificate",
+ "known_language",
+ "known_language_other",
+ "special_diet",
+ "special_diet_other",
+ "accommodation",
+ "prior_experience",
+ "shift_wishes",
+ "free_text",
+ )
+
+ widgets = dict(
+ known_language=forms.CheckboxSelectMultiple,
+ special_diet=forms.CheckboxSelectMultiple,
+ accommodation=forms.CheckboxSelectMultiple,
+ work_days=forms.CheckboxSelectMultiple,
+ )
+
+
+class ShiftWishesSurvey(forms.ModelForm):
+ def __init__(self, *args, **kwargs):
+ kwargs.pop("event")
+
+ super().__init__(*args, **kwargs)
+
+ self.helper = horizontal_form_helper()
+ self.helper.form_tag = False
+
+ @classmethod
+ def get_instance_for_event_and_person(cls, event, person):
+ return SignupExtra.objects.get(event=event, person=person)
+
+ class Meta:
+ model = SignupExtra
+ fields = (
+ "shift_wishes",
+ "accommodation",
+ )
+ widgets = dict(
+ accommodation=forms.CheckboxSelectMultiple,
+ )
diff --git a/kompassi/events/kotaeexpo2026/forms/expense-claim-dimensions.yml b/kompassi/events/kotaeexpo2026/forms/expense-claim-dimensions.yml
new file mode 100644
index 000000000..920620056
--- /dev/null
+++ b/kompassi/events/kotaeexpo2026/forms/expense-claim-dimensions.yml
@@ -0,0 +1,118 @@
+- slug: account
+ title:
+ fi: Tiliöinti
+ en: Account
+ is_key_dimension: false
+ choices:
+ - slug: rent
+ title:
+ fi: 4100 Tilavuokrat
+ en: 4100 Venue rent
+ - slug: equipment-rental
+ title:
+ fi: 4200 Kalustevuokraus
+ en: 4200 Equipment rental
+ - slug: restaurants
+ title:
+ fi: 4300 Ravintolakulut
+ en: 4300 Restaurant expenses
+ - slug: performers
+ title:
+ fi: 5100 Esiintyjät
+ en: 5100 Performers
+ - slug: program
+ title:
+ fi: 5200 Ohjelma
+ en: 5200 Program
+ - slug: supplies
+ title:
+ fi: 5400 Tarvikkeet
+ en: 5400 Supplies
+ - slug: technology
+ title:
+ fi: 5500 Tekniikka
+ en: 5500 Technology
+ - slug: meeting-expenses
+ title:
+ fi: 6100 Kokouskulut
+ en: 6100 Meeting expenses
+ - slug: travel-and-accommodation-expenses
+ title:
+ fi: 6200 Matka- ja majoituskulut
+ en: 6200 Travel and accommodation expenses
+ - slug: advertising-and-marketing-expenses
+ title:
+ fi: 8100 Ilmoitus ja markkinointikulut
+ en: 8100 Advertising and marketing expenses
+ - slug: representation-expenses
+ title:
+ fi: 8200 Edustuskulut
+ en: 8200 Representation expenses
+ - slug: multi-year-acquisitions
+ title:
+ fi: 9100 Monivuotiset hankinnat
+ en: 9100 Multi-year acquisitions
+ - slug: other-event-expenses
+ title:
+ fi: 9200 Tapahtuman muut kulut
+ en: 9200 Other event expenses
+ - slug: financial-transaction-expenses
+ title:
+ fi: 9500 Rahaliikenteen kulut
+ en: 9500 Financial transaction expenses
+ - slug: loans-from-individuals
+ title:
+ fi: 9550 Lainat yksityishenkilöiltä
+ en: 9550 Loans from individuals
+ - slug: loans-from-associations
+ title:
+ fi: 9560 Lainat yhdistyksiltä
+ en: 9560 Loans from associations
+ - slug: loans-from-banks
+ title:
+ fi: 9570 Lainat pankilta
+ en: 9570 Loans from banks
+ - slug: insurances
+ title:
+ fi: 9700 Vakuutukset
+ en: 9700 Insurances
+ - slug: phone-connections
+ title:
+ fi: 9800 Puhelinliittymät
+ en: 9800 Phone connections
+ - slug: other-operating-expenses
+ title:
+ fi: 9900 Muut toiminnan menot
+ en: 9900 Other operating expenses
+
+- slug: status
+ title:
+ fi: Maksun tila
+ en: Payment status
+ is_key_dimension: true
+ choices:
+ - slug: new
+ color: blue
+ title:
+ fi: Uusi
+ en: New
+ - slug: accepted
+ color: green
+ title:
+ fi: Hyväksytty
+ en: Accepted
+ - slug: paid
+ color: teal
+ title:
+ fi: Maksettu
+ en: Paid
+ - slug: info-requested
+ color: yellow
+ title:
+ fi: Lisätietoja pyydetty
+ en: Further details requested
+ - slug: rejected
+ color: red
+ title:
+ fi: Hylätty
+ en: Rejected
diff --git a/kompassi/events/kotaeexpo2026/forms/expense-claim-en.yml b/kompassi/events/kotaeexpo2026/forms/expense-claim-en.yml
new file mode 100644
index 000000000..fa8c96b95
--- /dev/null
+++ b/kompassi/events/kotaeexpo2026/forms/expense-claim-en.yml
@@ -0,0 +1,59 @@
+title: Expense Claim
+description: |
+ With this form, you can apply for expense reimbursement from Kotae ry for an
+ event or association-related expense. Fill out the form carefully and attach
+ all requested attachments.
+
+ If you have not asked for prior approval for the expenses or if you have any
+ questions about expense reimbursements, please contact us by email at
+ talous at kotae dot fi or on Discord at @Nimu or
+ @Aketzu.
+
+fields:
+ - slug: title
+ type: SingleLineText
+ title: Title
+ required: true
+ helpText: Briefly tell us what expense you are applying for reimbursement for or what the invoice is for.
+
+ - slug: description
+ type: MultiLineText
+ title: Description
+ helpText: |
+ If the title does not tell everything essential, you can provide additional information here.
+
+ - slug: amount
+ type: DecimalField
+ minValue: 0
+ decimalPlaces: 2
+ title: Amount
+ required: true
+ helpText: |
+ How much are you applying for reimbursement or what is the invoice amount? Write the amount in euros.
+
+ - slug: recipient
+ type: SingleLineText
+ title: Recipient
+ required: true
+ helpText: |
+ Who will the reimbursement be paid to? Write the first and last name or the company name here.
+
+ - slug: recipient_iban
+ type: SingleLineText
+ title: Bank account number of the recipient
+ required: true
+ helpText: |
+ Which account will the reimbursement be paid to? Write the IBAN account number in the format FI12 3456 7890 1234 56.
+
+ - slug: attachments
+ type: FileUpload
+ title: Receipts
+ required: true
+ multiple: true
+ helpText: |
+ Attach all receipts necessary for the expense reimbursement.
+ The date of the expense, the amount, the VAT rate, and the recipient of the payment must be apparent from the receipts.
+ For payments made in a currency other than euros, the exchange rate or
+ the amount in the original currency must also be apparent.
+ If you do not have a receipt, write a free-form explanation of the expense and
+ why a receipt is not available, and sign it.
diff --git a/kompassi/events/kotaeexpo2026/forms/expense-claim-fi.yml b/kompassi/events/kotaeexpo2026/forms/expense-claim-fi.yml
new file mode 100644
index 000000000..832cf6fd9
--- /dev/null
+++ b/kompassi/events/kotaeexpo2026/forms/expense-claim-fi.yml
@@ -0,0 +1,58 @@
+title: Hae kulukorvausta
+description: |
+ Tällä lomakkeella voit hakea kulukorvausta Kotae ry:ltä tapahtumaan tai
+ yhdistystoimintaan liittyvästä kulusta. Täytä lomake huolellisesti ja liitä
+ mukaan kaikki pyydetyt liitteet.
+
+ Jos et ole kysynyt etukäteen lupaa kuluille tai jos sinulla on kysyttävää
+ kulukorvauksista, ota yhteyttä sähköpostitse osoitteella talous ät kotae
+ piste fi tai Discordissa @Nimu tai @Aketzu.
+
+fields:
+ - slug: title
+ type: SingleLineText
+ title: Otsikko
+ required: true
+ helpText: Kerro lyhyesti, mistä kulusta haet korvausta tai mitä lasku koskee.
+
+ - slug: description
+ type: MultiLineText
+ title: Kuvaus
+ helpText: |
+ Jos otsikko ei kerro kaikkea olennaista, voit antaa tässä lisätietoja.
+
+ - slug: amount
+ type: DecimalField
+ minValue: 0
+ decimalPlaces: 2
+ title: Summa
+ required: true
+ helpText: |
+ Kuinka paljon haet korvausta tai mikä on laskun summa? Kirjoita summa euroina.
+
+ - slug: recipient
+ type: SingleLineText
+ title: Saaja
+ required: true
+ helpText: |
+ Kenen tilille korvaus maksetaan? Kirjoita tähän etu- ja sukunimi tai yrityksen nimi.
+
+ - slug: recipient_iban
+ type: SingleLineText
+ title: Saajan tilinumero
+ required: true
+ helpText: |
+ Mille tilille korvaus maksetaan? Kirjoita IBAN-tilinumero muodossa FI12 3456 7890 1234 56.
+
+ - slug: attachments
+ type: FileUpload
+ title: Tositteet
+ required: true
+ multiple: true
+ helpText: |
+ Liitä mukaan kaikki kulukorvauksen kannalta tarpeelliset tositteet.
+ Tositteista tulee käydä ilmi kulun päivämäärä, summa, ALV-kanta ja maksun saaja.
+ Muussa valuutassa kuin euroissa tehdyistä maksuista tulee käydä ilmi myös valuuttakurssi tai
+ summa alkuperäisessä valuutassa.
+ Jos sinulla ei ole tositetta, kirjoita vapaamuotoinen selvitys kulusta ja
+ siitä, miksi tositetta ei ole saatavilla, ja allekirjoita se.
diff --git a/kompassi/events/kotaeexpo2026/management/__init__.py b/kompassi/events/kotaeexpo2026/management/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/kompassi/events/kotaeexpo2026/management/commands/__init__.py b/kompassi/events/kotaeexpo2026/management/commands/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/kompassi/events/kotaeexpo2026/management/commands/setup_kotaeexpo2026.py b/kompassi/events/kotaeexpo2026/management/commands/setup_kotaeexpo2026.py
new file mode 100644
index 000000000..e29671fca
--- /dev/null
+++ b/kompassi/events/kotaeexpo2026/management/commands/setup_kotaeexpo2026.py
@@ -0,0 +1,382 @@
+from datetime import datetime, timedelta
+
+from dateutil.tz import tzlocal
+from django.conf import settings
+from django.contrib.auth.models import Group
+from django.contrib.contenttypes.models import ContentType
+from django.core.management.base import BaseCommand
+from django.utils.timezone import now
+
+from kompassi.access.models.email_alias_domain import EmailAliasDomain
+from kompassi.access.models.email_alias_type import EmailAliasVariant
+from kompassi.access.models.group_email_alias_grant import GroupEmailAliasGrant
+from kompassi.badges.models import BadgesEventMeta
+from kompassi.core.models import Event, Organization, Venue
+from kompassi.forms.models.meta import FormsEventMeta
+from kompassi.forms.models.survey import SurveyDTO
+from kompassi.intra.models import IntraEventMeta, Team
+from kompassi.involvement.models.involvement_to_badge import InvolvementToBadgeMapping
+from kompassi.involvement.models.involvement_to_group import InvolvementToGroupMapping
+from kompassi.involvement.models.meta import InvolvementEventMeta
+from kompassi.involvement.models.registry import Registry
+from kompassi.labour.models import AlternativeSignupForm, JobCategory, LabourEventMeta, PersonnelClass, Survey
+from kompassi.labour.models.qualifications import Qualification
+from kompassi.program_v2.models.meta import ProgramV2EventMeta
+
+from ...models import Accommodation, KnownLanguage, SignupExtra, WorkDay
+
+
+class Setup:
+ def __init__(self):
+ self._ordering = 0
+
+ def get_ordering_number(self):
+ self._ordering += 10
+ return self._ordering
+
+ def setup(self, test=False):
+ self.test = test
+ self.tz = tzlocal()
+ self.setup_core()
+ self.setup_labour()
+ self.setup_badges()
+ self.setup_intra()
+ self.setup_access()
+ self.setup_forms()
+ self.setup_program_v2()
+
+ def setup_core(self):
+ self.organization, unused = Organization.objects.get_or_create(
+ slug="kotae-ry",
+ defaults=dict(
+ name="Kotae ry",
+ homepage_url="https://www.kotae.fi",
+ ),
+ )
+ self.venue, unused = Venue.objects.get_or_create(name="Tampereen Messu- ja Urheilukeskus")
+ self.event, unused = Event.objects.get_or_create(
+ slug="kotaeexpo2026",
+ defaults=dict(
+ name="Kotae Expo (2026)",
+ name_genitive="Kotae Expon",
+ name_illative="Kotae Expoon",
+ name_inessive="Kotae Expossa",
+ homepage_url="http://www.kotae.fi",
+ organization=self.organization,
+ start_time=datetime(2026, 11, 21, 10, 0, tzinfo=self.tz),
+ end_time=datetime(2026, 11, 22, 18, 0, tzinfo=self.tz),
+ venue=self.venue,
+ ),
+ )
+
+ def setup_labour(self):
+ (labour_admin_group,) = LabourEventMeta.get_or_create_groups(self.event, ["admins"])
+
+ content_type = ContentType.objects.get_for_model(SignupExtra)
+
+ labour_event_meta_defaults = dict(
+ signup_extra_content_type=content_type,
+ work_begins=self.event.start_time.replace(day=20, hour=8, minute=0, tzinfo=self.tz),
+ work_ends=self.event.end_time.replace(hour=22, minute=0, tzinfo=self.tz),
+ admin_group=labour_admin_group,
+ contact_email="Kotae Expon vapaaehtoistiimi ",
+ )
+
+ if self.test:
+ t = now()
+ labour_event_meta_defaults.update(
+ registration_opens=t - timedelta(days=60), # type: ignore
+ registration_closes=t + timedelta(days=60), # type: ignore
+ )
+ else:
+ pass
+
+ labour_event_meta, unused = LabourEventMeta.objects.get_or_create(
+ event=self.event,
+ defaults=labour_event_meta_defaults,
+ )
+
+ for pc_name, pc_slug, pc_app_label in [
+ ("Vastaava", "vastaava", "labour"),
+ ("Vapaaehtoinen", "vapaaehtoinen", "labour"),
+ ("Ohjelmanjärjestäjä", "ohjelma", "program_v2"),
+ ("Vieras", "vieras", "badges"),
+ # ("Ohjelmanpitäjä lauantai", "ohjelmanpitaja-la", "program_v2"),
+ # ("Ohjelmanpitäjä sunnuntai", "ohjelmanpitaja-su", "program_v2"),
+ # ("Ohjelmanpitäjä viikonloppu", "ohjelmanpitaja-viikonloppu", "program_v2"),
+ # ("Artesaani", "artesaani", "badges"),
+ # ("Taidekuja lauantai", "taidekuja-la", "badges"),
+ # ("Taidekuja sunnuntai", "taidekuja-su", "badges"),
+ # ("Expo näytteilleasettaja", "exponaytteilleasettaja", "badges"),
+ # ("Näytteilleasettaja", "naytteilleasettaja", "badges"),
+ ]:
+ PersonnelClass.objects.update_or_create(
+ event=self.event,
+ slug=pc_slug,
+ defaults=dict(
+ name=pc_name,
+ app_label=pc_app_label,
+ priority=self.get_ordering_number(),
+ ),
+ )
+
+ if not JobCategory.objects.filter(event=self.event).exists():
+ previous_event = Event.objects.filter(slug=f"kotaeexpo{self.event.start_time.year - 1}").first()
+ if previous_event:
+ JobCategory.copy_from_event(source_event=previous_event, target_event=self.event)
+
+ # Ensure required job categories exist
+ vapaaehtoinen = PersonnelClass.objects.get(event=self.event, slug="vapaaehtoinen")
+ vastaava = PersonnelClass.objects.get(event=self.event, slug="vastaava")
+ for jc_data in [
+ ("vastaava", "Vastaava", "Tapahtuman järjestelytoimikunnan jäsen", [vastaava]),
+ (
+ "jarjestyksenvalvoja",
+ "Järjestyksenvalvoja",
+ "Järjestyksenvalvojat valvovat kävijöiden turvallisuutta tapahtumassa. Tehtävä edellyttää täysi-ikäisyyttä, voimassa olevaa JV-korttia ja asiakaspalveluasennetta.",
+ [vapaaehtoinen],
+ ),
+ ]:
+ slug, name, description, pcs = jc_data
+
+ job_category, created = JobCategory.objects.get_or_create(
+ event=self.event,
+ slug=slug,
+ defaults=dict(
+ name=name,
+ description=description,
+ ),
+ )
+
+ if created:
+ job_category.personnel_classes.set(pcs)
+
+ for slug in ["vastaava"]:
+ JobCategory.objects.filter(event=self.event, slug=slug).update(public=False)
+
+ for jc_slug, qualification_name in [
+ ("jarjestyksenvalvoja", "JV-kortti"),
+ ]:
+ jc = JobCategory.objects.get(event=self.event, slug=jc_slug)
+ qual = Qualification.objects.get(name=qualification_name)
+
+ jc.required_qualifications.set([qual])
+
+ labour_event_meta.create_groups()
+
+ for language in [
+ "Suomi",
+ "Ruotsi",
+ "Englanti",
+ "Japani",
+ "Korea",
+ ]:
+ KnownLanguage.objects.get_or_create(name=language)
+
+ for accommodation_name in [
+ "Pe-la väliselle yölle",
+ "La-su väliselle yölle",
+ ]:
+ Accommodation.objects.get_or_create(name=accommodation_name)
+
+ for workday_name in [
+ "Tapahtumaviikon keskiviikko ja torstai (logistiikka)",
+ "Perjantai",
+ "Lauantai",
+ "Sunnuntai",
+ "Tapahtuman jälkeinen maanantai ja tiistai (logistiikka)",
+ ]:
+ WorkDay.objects.get_or_create(name=workday_name)
+
+ AlternativeSignupForm.objects.get_or_create(
+ event=self.event,
+ slug="vastaava",
+ defaults=dict(
+ title="Vastaavien ilmoittautumislomake",
+ signup_form_class_path="events.kotaeexpo2026.forms:OrganizerSignupForm",
+ signup_extra_form_class_path="events.kotaeexpo2026.forms:OrganizerSignupExtraForm",
+ active_from=now(),
+ active_until=self.event.end_time,
+ ),
+ )
+
+ AlternativeSignupForm.objects.get_or_create(
+ event=self.event,
+ slug="erikoistehtava",
+ defaults=dict(
+ title="Erikoistehtävien ilmoittautumislomake",
+ signup_form_class_path="events.kotaeexpo2026.forms:SpecialistSignupForm",
+ signup_extra_form_class_path="events.kotaeexpo2026.forms:SpecialistSignupExtraForm",
+ active_from=self.event.created_at,
+ active_until=self.event.start_time,
+ signup_message=(
+ "Täytä tämä lomake vain, "
+ "jos joku Kotae Expon vastaavista on ohjeistanut sinua ilmoittautumaan tällä lomakkeella. "
+ ),
+ ),
+ )
+
+ Survey.objects.get_or_create(
+ event=self.event,
+ slug="tyovuorotoiveet",
+ defaults=dict(
+ title="Työvuorotoiveet",
+ description=(
+ "Tässä vaiheessa voit vaikuttaa työvuoroihisi. Jos saavut tapahtumaan vasta sen alkamisen "
+ "jälkeen tai sinun täytyy lähteä ennen tapahtuman loppumista, kerro se tässä. Lisäksi jos "
+ "tiedät ettet ole käytettävissä tiettyihin aikoihin tapahtuman aikana tai haluat esimerkiksi "
+ "nähdä jonkun ohjelmanumeron, kerro siitäkin. Työvuorotoiveiden toteutumista täysin ei voida "
+ "taata."
+ ),
+ form_class_path="events.kotaeexpo2026.forms:ShiftWishesSurvey",
+ active_from=now(),
+ active_until=self.event.start_time - timedelta(days=60),
+ ),
+ )
+
+ def setup_badges(self):
+ (badge_admin_group,) = BadgesEventMeta.get_or_create_groups(self.event, ["admins"])
+ meta, unused = BadgesEventMeta.objects.get_or_create(
+ event=self.event,
+ defaults=dict(
+ admin_group=badge_admin_group,
+ real_name_must_be_visible=False,
+ ),
+ )
+
+ def setup_access(self):
+ cc_group = self.event.labour_event_meta.get_group("vastaava")
+ domain = EmailAliasDomain.objects.get(domain_name="kotae.fi")
+ GroupEmailAliasGrant.ensure(
+ cc_group,
+ domain,
+ [
+ EmailAliasVariant.FIRSTNAME_LASTNAME,
+ EmailAliasVariant.CUSTOM,
+ ],
+ )
+
+ def setup_intra(self):
+ (admin_group,) = IntraEventMeta.get_or_create_groups(self.event, ["admins"])
+ organizer_group = self.event.labour_event_meta.get_group("vastaava")
+ IntraEventMeta.objects.update_or_create(
+ event=self.event,
+ defaults=dict(
+ admin_group=admin_group,
+ organizer_group=organizer_group,
+ is_organizer_list_public=True,
+ ),
+ )
+
+ for team_slug, team_name in [
+ ("pj", "Pääjärjestäjä"),
+ ("grafiikka", "Grafiikka"),
+ ("kunniavieraat", "Kunniavieraat"),
+ ("markkinointi", "Markkinointi"),
+ ("ohjelma", "Ohjelma"),
+ ("talous", "Talous"),
+ ("taltiointi", "Taltiointi"),
+ ("tapahtumakehitys", "Tapahtumakehitys- ja laatu"),
+ ("tekniikka", "Tekniikka"),
+ ("tilat", "Tilat"),
+ ("turvallisuus", "Turvallisuus"),
+ ("vapaaehtoiset", "Vapaaehtoiset"),
+ ("viestinta", "Viestintä"),
+ ]:
+ (team_group,) = IntraEventMeta.get_or_create_groups(self.event, [team_slug])
+ email = f"{team_slug}@kotae.fi"
+
+ team, _ = Team.objects.get_or_create(
+ event=self.event,
+ slug=team_slug,
+ defaults=dict(
+ name=team_name,
+ order=self.get_ordering_number(),
+ group=team_group,
+ email=email,
+ ),
+ )
+
+ for team in Team.objects.filter(event=self.event):
+ team.is_public = True
+ team.save()
+
+ def setup_forms(self):
+ (admin_group,) = FormsEventMeta.get_or_create_groups(self.event, ["admins"])
+
+ FormsEventMeta.objects.get_or_create(
+ event=self.event,
+ defaults=dict(
+ admin_group=admin_group,
+ ),
+ )
+
+ for survey in [
+ SurveyDTO(
+ slug="expense-claim",
+ key_fields=["title", "amount"],
+ login_required=True,
+ anonymity="NAME_AND_EMAIL",
+ active_from=datetime(2026, 1, 1, 0, 0, tzinfo=self.tz),
+ active_until=datetime(2026, 12, 31, 23, 59, tzinfo=self.tz),
+ ),
+ ]:
+ survey.save(self.event)
+
+ def setup_program_v2(self):
+ InvolvementEventMeta.ensure(self.event)
+
+ (admin_group,) = ProgramV2EventMeta.get_or_create_groups(self.event, ["admins"])
+
+ # TODO(Kotae Expo): Define your volunteer registry
+ registry, _created = Registry.objects.get_or_create(
+ scope=self.organization.scope,
+ slug="volunteers",
+ defaults=dict(
+ title_fi="Kotae ry:n vapaaehtoisrekisteri",
+ title_en="Volunteers of Kotae ry",
+ ),
+ )
+
+ ProgramV2EventMeta.objects.update_or_create(
+ event=self.event,
+ defaults=dict(
+ admin_group=admin_group,
+ default_registry=registry,
+ contact_email="Kotae Expon ohjelmatiimi ",
+ ),
+ )
+
+ universe = self.event.involvement_universe
+
+ ohjelma = PersonnelClass.objects.get(event=self.event, slug="ohjelma")
+ InvolvementToBadgeMapping.objects.update_or_create(
+ universe=universe,
+ personnel_class=ohjelma,
+ defaults=dict(
+ required_dimensions={
+ "state": ["active"],
+ "type": ["program-host"],
+ },
+ job_title="Ohjelmanjärjestäjä",
+ priority=self.get_ordering_number(),
+ ),
+ )
+
+ group, _ = Group.objects.get_or_create(name=f"{self.event.slug}-program-hosts")
+ InvolvementToGroupMapping.objects.get_or_create(
+ universe=universe,
+ required_dimensions={
+ "state": ["active"],
+ "type": ["program-host"],
+ },
+ group=group,
+ )
+
+
+class Command(BaseCommand):
+ args = ""
+ help = "Setup Kotae Expo 2026 specific stuff"
+
+ def handle(self, *args, **opts):
+ Setup().setup(test=settings.DEBUG)
diff --git a/kompassi/events/kotaeexpo2026/migrations/0001_initial.py b/kompassi/events/kotaeexpo2026/migrations/0001_initial.py
new file mode 100644
index 000000000..0e9bacacb
--- /dev/null
+++ b/kompassi/events/kotaeexpo2026/migrations/0001_initial.py
@@ -0,0 +1,220 @@
+# Generated by Django 6.0.1 on 2026-02-01 19:33
+
+import django.core.validators
+import django.db.models.deletion
+from django.db import migrations, models
+
+import kompassi.labour.models.signup_extras
+
+
+class Migration(migrations.Migration):
+ initial = True
+
+ dependencies = [
+ ("core", "0043_emailverificationtoken_language_and_more"),
+ ("enrollment", "0009_alter_enrollment_is_public_and_more"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="AccessibilityWarning",
+ fields=[
+ ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
+ ("name", models.CharField(max_length=63)),
+ ],
+ options={
+ "abstract": False,
+ },
+ ),
+ migrations.CreateModel(
+ name="Accommodation",
+ fields=[
+ ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
+ ("name", models.CharField(max_length=63)),
+ ],
+ options={
+ "abstract": False,
+ },
+ ),
+ migrations.CreateModel(
+ name="KnownLanguage",
+ fields=[
+ ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
+ ("name", models.CharField(max_length=63)),
+ ],
+ options={
+ "abstract": False,
+ },
+ ),
+ migrations.CreateModel(
+ name="TimeSlot",
+ fields=[
+ ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
+ ("name", models.CharField(max_length=63)),
+ ],
+ options={
+ "abstract": False,
+ },
+ ),
+ migrations.CreateModel(
+ name="WorkDay",
+ fields=[
+ ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
+ ("name", models.CharField(max_length=63)),
+ ],
+ options={
+ "abstract": False,
+ },
+ ),
+ migrations.CreateModel(
+ name="SignupExtra",
+ fields=[
+ ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
+ ("is_active", models.BooleanField(default=True)),
+ (
+ "shift_type",
+ models.CharField(
+ choices=[
+ ("yksipitka", "Yksi pitkä vuoro"),
+ ("montalyhytta", "Monta lyhyempää vuoroa"),
+ ("kaikkikay", "Kumpi tahansa käy"),
+ ],
+ help_text="Haluatko tehdä yhden pitkän työvuoron vaiko monta lyhyempää vuoroa?",
+ max_length=15,
+ verbose_name="Toivottu työvuoron pituus",
+ ),
+ ),
+ (
+ "total_work",
+ models.CharField(
+ choices=[("8h", "8 tuntia - Täysvuoro"), ("yli10h", "Yli 10 tuntia - Supervuoro!")],
+ help_text="Kuinka paljon haluat tehdä töitä yhteensä tapahtuman aikana? Huomaathan, ettei 4 tunnin työpanos oikeuta täysiin työvoimaetuihin. Tarkasta työvoimaedut Kotaen verkkosivuilta.",
+ max_length=15,
+ verbose_name="Toivottu kokonaistyömäärä",
+ ),
+ ),
+ (
+ "night_shift",
+ models.BooleanField(default=False, verbose_name="Olen valmis tekemään yötöitä klo 23-07"),
+ ),
+ (
+ "overseer",
+ models.BooleanField(
+ default=False,
+ help_text="Vuorovastaavat ovat kokeneempia conityöläisiä, jotka toimivat oman tehtäväalueensa tiiminvetäjänä.",
+ verbose_name="Olen kiinnostunut vuorovastaavan tehtävistä",
+ ),
+ ),
+ (
+ "want_certificate",
+ models.BooleanField(default=False, verbose_name="Haluan todistuksen työskentelystäni"),
+ ),
+ ("known_language_other", models.TextField(blank=True, verbose_name="Muu kieli")),
+ (
+ "special_diet_other",
+ models.TextField(
+ blank=True,
+ help_text="Jos noudatat erikoisruokavaliota, jota ei ole yllä olevassa listassa, ilmoita se tässä. Tapahtuman järjestäjä pyrkii ottamaan erikoisruokavaliot huomioon, mutta kaikkia erikoisruokavalioita ei välttämättä pystytä järjestämään.",
+ verbose_name="Muu erikoisruokavalio",
+ ),
+ ),
+ (
+ "prior_experience",
+ models.TextField(
+ blank=True,
+ help_text="Kerro tässä kentässä, jos sinulla on aiempaa kokemusta vastaavista tehtävistä tai muuta sellaista työkokemusta, josta arvioit olevan hyötyä hakemassasi tehtävässä.",
+ verbose_name="Työkokemus",
+ ),
+ ),
+ (
+ "free_text",
+ models.TextField(
+ blank=True,
+ help_text="Jos haluat sanoa hakemuksesi käsittelijöille jotain sellaista, jolle ei ole omaa kenttää yllä, käytä tätä kenttää.",
+ verbose_name="Vapaa alue",
+ ),
+ ),
+ (
+ "shift_wishes",
+ models.TextField(
+ blank=True,
+ help_text="Jos tiedät, ettet pääse paikalle johonkin tiettyyn aikaan tai haluat esimerkiksi osallistua johonkin tiettyyn ohjelmanumeroon, mainitse siitä tässä. HUOM! Muistathan mainita kellonajat (myös ohjelmanumeroista).",
+ verbose_name="Työvuorotoiveet",
+ ),
+ ),
+ (
+ "email_alias",
+ models.CharField(
+ blank=True,
+ default="",
+ help_text="Coniitit saavat käyttöönsä nick@kotae.fi-tyyppisen sähköpostialiaksen, joka ohjataan coniitin omaan sähköpostilaatikkoon. Tässä voit toivoa haluamaasi sähköpostialiaksen alkuosaa eli sitä, joka tulee ennen @kotae.fi:tä. Sallittuja merkkejä ovat pienet kirjaimet a-z, numerot 0-9 sekä väliviiva.",
+ max_length=32,
+ validators=[
+ django.core.validators.RegexValidator(
+ message="Tekninen nimi saa sisältää vain pieniä kirjaimia, numeroita sekä väliviivoja.",
+ regex="^[a-z0-9-]+$",
+ )
+ ],
+ verbose_name="Sähköpostialias",
+ ),
+ ),
+ (
+ "accommodation",
+ models.ManyToManyField(
+ blank=True,
+ related_name="kotaeexpo2026_signup_extras",
+ to="kotaeexpo2026.accommodation",
+ verbose_name="Tarvitsen lattiamajoitusta",
+ ),
+ ),
+ (
+ "event",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="%(app_label)s_signup_extras",
+ to="core.event",
+ ),
+ ),
+ (
+ "known_language",
+ models.ManyToManyField(
+ blank=True,
+ related_name="kotaeexpo2026_signup_extras",
+ to="kotaeexpo2026.knownlanguage",
+ verbose_name="Osaamasi kielet",
+ ),
+ ),
+ (
+ "person",
+ models.OneToOneField(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="%(app_label)s_signup_extra",
+ to="core.person",
+ ),
+ ),
+ (
+ "special_diet",
+ models.ManyToManyField(
+ blank=True,
+ related_name="kotaeexpo2026_signup_extras",
+ to="enrollment.specialdiet",
+ verbose_name="Erikoisruokavalio",
+ ),
+ ),
+ (
+ "work_days",
+ models.ManyToManyField(
+ blank=True,
+ help_text="Merkitse ne päivät, jolloin olet käytettävissä vapaaehtoisena.",
+ related_name="kotaeexpo2026_signup_extras",
+ to="kotaeexpo2026.workday",
+ verbose_name="Työskentelypäivät",
+ ),
+ ),
+ ],
+ options={
+ "abstract": False,
+ },
+ bases=(kompassi.labour.models.signup_extras.SignupExtraMixin, models.Model),
+ ),
+ ]
diff --git a/kompassi/events/kotaeexpo2026/migrations/__init__.py b/kompassi/events/kotaeexpo2026/migrations/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/kompassi/events/kotaeexpo2026/models.py b/kompassi/events/kotaeexpo2026/models.py
new file mode 100644
index 000000000..1bcc69a73
--- /dev/null
+++ b/kompassi/events/kotaeexpo2026/models.py
@@ -0,0 +1,171 @@
+from django.db import models
+
+from kompassi.core.utils import validate_slug
+from kompassi.labour.models import SignupExtraBase
+from kompassi.zombies.enrollment.models import SimpleChoice, SpecialDiet
+
+SHIFT_TYPE_CHOICES = [
+ ("yksipitka", "Yksi pitkä vuoro"),
+ ("montalyhytta", "Monta lyhyempää vuoroa"),
+ ("kaikkikay", "Kumpi tahansa käy"),
+]
+
+TOTAL_WORK_CHOICES = [
+ ("8h", "8 tuntia - Täysvuoro"),
+ ("yli10h", "Yli 10 tuntia - Supervuoro!"),
+]
+
+
+class Accommodation(SimpleChoice):
+ pass
+
+
+class WorkDay(SimpleChoice):
+ pass
+
+
+class KnownLanguage(SimpleChoice):
+ pass
+
+
+class SignupExtra(SignupExtraBase):
+ shift_type = models.CharField(
+ max_length=15,
+ verbose_name="Toivottu työvuoron pituus",
+ help_text="Haluatko tehdä yhden pitkän työvuoron vaiko monta lyhyempää vuoroa?",
+ choices=SHIFT_TYPE_CHOICES,
+ )
+
+ total_work = models.CharField(
+ max_length=15,
+ verbose_name="Toivottu kokonaistyömäärä",
+ help_text=(
+ "Kuinka paljon haluat tehdä töitä yhteensä tapahtuman aikana? Huomaathan, ettei 4 tunnin työpanos oikeuta täysiin työvoimaetuihin. Tarkasta työvoimaedut Kotaen verkkosivuilta."
+ ),
+ choices=TOTAL_WORK_CHOICES,
+ )
+
+ night_shift = models.BooleanField(
+ default=False,
+ verbose_name="Olen valmis tekemään yötöitä klo 23-07",
+ )
+
+ overseer = models.BooleanField(
+ default=False,
+ verbose_name="Olen kiinnostunut vuorovastaavan tehtävistä",
+ help_text=(
+ "Vuorovastaavat ovat kokeneempia conityöläisiä, jotka toimivat oman tehtäväalueensa tiiminvetäjänä."
+ ),
+ )
+
+ work_days = models.ManyToManyField(
+ WorkDay,
+ blank=True,
+ verbose_name="Työskentelypäivät",
+ help_text=("Merkitse ne päivät, jolloin olet käytettävissä vapaaehtoisena."),
+ related_name="kotaeexpo2026_signup_extras",
+ )
+
+ want_certificate = models.BooleanField(
+ default=False,
+ verbose_name="Haluan todistuksen työskentelystäni",
+ )
+
+ known_language = models.ManyToManyField(
+ KnownLanguage,
+ blank=True,
+ verbose_name="Osaamasi kielet",
+ related_name="kotaeexpo2026_signup_extras",
+ )
+
+ known_language_other = models.TextField(
+ blank=True,
+ verbose_name="Muu kieli",
+ )
+
+ special_diet = models.ManyToManyField(
+ SpecialDiet,
+ blank=True,
+ verbose_name="Erikoisruokavalio",
+ related_name="kotaeexpo2026_signup_extras",
+ )
+
+ special_diet_other = models.TextField(
+ blank=True,
+ verbose_name="Muu erikoisruokavalio",
+ help_text=(
+ "Jos noudatat erikoisruokavaliota, jota ei ole yllä olevassa listassa, "
+ "ilmoita se tässä. Tapahtuman järjestäjä pyrkii ottamaan erikoisruokavaliot "
+ "huomioon, mutta kaikkia erikoisruokavalioita ei välttämättä pystytä järjestämään."
+ ),
+ )
+
+ accommodation = models.ManyToManyField(
+ Accommodation,
+ blank=True,
+ verbose_name="Tarvitsen lattiamajoitusta",
+ related_name="kotaeexpo2026_signup_extras",
+ )
+
+ prior_experience = models.TextField(
+ blank=True,
+ verbose_name="Työkokemus",
+ help_text=(
+ "Kerro tässä kentässä, jos sinulla on aiempaa kokemusta vastaavista "
+ "tehtävistä tai muuta sellaista työkokemusta, josta arvioit olevan hyötyä "
+ "hakemassasi tehtävässä."
+ ),
+ )
+
+ free_text = models.TextField(
+ blank=True,
+ verbose_name="Vapaa alue",
+ help_text=(
+ "Jos haluat sanoa hakemuksesi käsittelijöille jotain sellaista, jolle ei ole "
+ "omaa kenttää yllä, käytä tätä kenttää."
+ ),
+ )
+
+ shift_wishes = models.TextField(
+ blank=True,
+ verbose_name="Työvuorotoiveet",
+ help_text=(
+ "Jos tiedät, ettet pääse paikalle johonkin tiettyyn aikaan tai haluat esimerkiksi "
+ "osallistua johonkin tiettyyn ohjelmanumeroon, mainitse siitä tässä. HUOM! Muistathan "
+ "mainita kellonajat (myös ohjelmanumeroista)."
+ ),
+ )
+
+ email_alias = models.CharField(
+ blank=True,
+ default="",
+ max_length=32,
+ verbose_name="Sähköpostialias",
+ help_text=(
+ "Coniitit saavat käyttöönsä nick@kotae.fi-tyyppisen sähköpostialiaksen, joka "
+ "ohjataan coniitin omaan sähköpostilaatikkoon. Tässä voit toivoa haluamaasi "
+ "sähköpostialiaksen alkuosaa eli sitä, joka tulee ennen @kotae.fi:tä. "
+ "Sallittuja merkkejä ovat pienet kirjaimet a-z, numerot 0-9 sekä väliviiva."
+ ),
+ validators=[validate_slug],
+ )
+
+ @classmethod
+ def get_form_class(cls):
+ from .forms import SignupExtraForm
+
+ return SignupExtraForm
+
+ @classmethod
+ def get_programme_form_class(cls):
+ from .forms import ProgrammeSignupExtraForm
+
+ return ProgrammeSignupExtraForm
+
+
+class TimeSlot(SimpleChoice):
+ pass
+
+
+class AccessibilityWarning(SimpleChoice):
+ pass
diff --git a/kompassi/settings.py b/kompassi/settings.py
index be019fefd..7a16af347 100644
--- a/kompassi/settings.py
+++ b/kompassi/settings.py
@@ -286,6 +286,7 @@
"kompassi.events.kuplii2026",
"kompassi.events.desucon2026",
"kompassi.events.tracon2026",
+ "kompassi.events.kotaeexpo2026",
# zombies are obsolete apps that can't be removed due to cross-app references in models
"kompassi.zombies.enrollment",
"kompassi.zombies.event_log",