Compare commits

...

24 Commits

Author SHA1 Message Date
9d28be16ba Merge pull request 'Activity system' (#3) from tiago/activities into master
Reviewed-on: #3
2026-10-17 15:51:00 +00:00
f308f96616 added default value for checkboxes 2026-10-17 17:39:40 +02:00
249e6851b4 fixed bug (python is weak, no my fault i swear) 2026-10-17 17:37:37 +02:00
5c4aac62cb third typo hihi 2026-10-17 17:26:22 +02:00
d94e1bda81 another typo 2026-10-17 17:25:38 +02:00
0cc1bf4861 typo 2026-10-17 17:23:59 +02:00
5e4787665e added extra step 2026-10-17 17:23:45 +02:00
22859b679f renamed collection to event to match folder name 2026-10-17 17:09:12 +02:00
9f513271ac rename to singular 2026-10-17 17:07:20 +02:00
71853ac32d New type + questions 2026-10-17 16:51:41 +02:00
74a4c947a1 new question type 2026-10-17 16:50:38 +02:00
36dadd7ac1 rename 2026-10-17 16:35:19 +02:00
5e7f72ea2f added new union types 2026-10-14 21:31:33 +02:00
0c1683c74b Added function which ckecs if a form category is a question 2026-10-14 20:25:13 +02:00
c0e592e973 added collection to list 2026-10-14 16:54:27 +02:00
f8c576263c system for validating answers to signup forms 2026-10-14 10:04:28 +02:00
6d8b06dfc7 consistent naming 2026-10-14 09:26:00 +02:00
0252f2aeb6 fixed typo 2026-10-13 21:58:34 +02:00
0fd92120d9 rename + added validation of answer to question 2026-10-13 21:47:39 +02:00
d855b862ca Basic signup form & answer system 2026-10-13 21:40:37 +02:00
9729b89477 rename to singular 2026-10-13 16:57:27 +02:00
4c62bc4de8 started working on activity system 2026-10-13 13:37:57 +02:00
337d27425e added payment info to activities 2026-10-12 17:08:05 +02:00
8c9d6bc6ef Started working on events/activities based on Florians work 2026-10-11 22:10:58 +02:00
24 changed files with 507 additions and 3 deletions

View File

@ -1,4 +1,4 @@
from pydantic import model_validator, EmailStr, computed_field, field_serializer from pydantic import model_validator, EmailStr, field_serializer
from typing import Self, Literal from typing import Self, Literal
from datetime import date from datetime import date
@ -31,7 +31,7 @@ class PersonModel(FiberBaseModel):
if self.date_of_birth is None: if self.date_of_birth is None:
return False return False
if ref_date is None: if ref_date is None:
ref_data = date.today() ref_date = date.today()
age = ( ref_date.year - self.date_of_birth.year age = ( ref_date.year - self.date_of_birth.year
- ( (ref_date.month, ref_date.day) - ( (ref_date.month, ref_date.day)
< (self.date_of_birth.month, self.date_of_birth.day) ) < (self.date_of_birth.month, self.date_of_birth.day) )

View File

@ -0,0 +1,75 @@
from typing import Literal, Self
from pydantic import model_validator
from datetime import datetime
from .communication import CommunicationModel
from .submodels import ActivityPaymentInfo
from .enums import CommunicationCategory
from fiber_package.refs import SignupRefList
from fiber_package.signup.signup import SignupModel
from fiber_package.signup.util import validate_answer
from fiber_package.signup.enums import is_question_category
from fiber_package.signup.element.form_base_question import FormBaseQuestion
from fiber_package.signup.element.types import FormElementUnion, FormTextUnion
class ActivityModel(CommunicationModel):
category: Literal[CommunicationCategory.activity]
slug: str
# unique short name used in the public URL
start_time: datetime
end_time: datetime
payment_info: ActivityPaymentInfo | None = None
form: list[FormElementUnion]
signups: SignupRefList = SignupRefList([], "signup")
# TODO: see Felt 'activiteiten' DB schema. Things like:
# email, target group, maximal attendees ....
@model_validator(mode='after')
def check_end_after_start(self) -> Self:
if self.end_time < self.start_time:
raise ValueError("End time cannot be earlier than start time")
return self
def validate_signup_form(self, signup: SignupModel) -> None:
required = set()
answerable = set()
question_dict: dict[str, FormBaseQuestion] = {}
for element in self.form:
if not is_question_category(element.category):
continue
# so we know it is istance of FormBaseQuestion
answerable.add(element.name)
if element.required:
required.add(element.name)
question_dict[element.name] = element
answers = signup.answers
for answer in answers:
q_name = answer.question_name
if q_name not in answerable:
raise ValueError(
f"Form contains answer to "
f"non-existing question {q_name}."
)
answerable.remove(q_name)
if answer.question_name in required:
required.remove(q_name)
question = question_dict[q_name]
validate_answer(question, answer)
if len(required) > 0:
raise ValueError("Form does not answer all required questions.")

View File

@ -0,0 +1,44 @@
from pydantic import Field, HttpUrl
from fiber_package.base import FiberBaseModel
from .enums import CommunicationCategory
'''
A "communication" is a piece of communication/promotion to be shared with
members, which may appear on the website and in the weekly mailing.
'''
class CommunicationModel(FiberBaseModel):
category: CommunicationCategory
name_en: str
name_nl: str
association_tags: list[str] = Field(default_factory=list)
# list of associations this communication belongs to
subtitle_en: str
subtitle_nl: str
# subtitles currently (2026) appear only in the table of contents in the
# weekly mailing; they are meant to be a short catchy flavour text
description_en: str
description_nl: str
image: HttpUrl | None = None
# often, a poster comes associated with a communication (in particular if
# it's an activity). In that case, the poster is uploaded to the cloud and
# the share link is attached to the communication
# TODO: potentially rethink the way images are attached to communications
visibility: bool
# should this communication be shown to members at all? Useful for
# temporarily hiding a communication
only_members: bool
# is this communication only visible to members? This refers to visibility
# on the website, i.e. is this only visible to visitors who are logged in?
archived: bool
# an archived communication cannot be edited, only viewed for future
# reference

View File

@ -0,0 +1,10 @@
from enum import StrEnum
'''
The category is only used to place the communication correctly in the weekly
mailing and the board-website, it is not visible (or relevant) to readers
'''
class CommunicationCategory(StrEnum):
announcement = "announcement"
news = "news"
activity = "activity"

View File

@ -0,0 +1,48 @@
Okay some ideas:
We want a modular system.
A signup form = list of signup questions
A signup = list of signup answers
Examples of questions:
* Dietary restrictions
* Insurance info
* Pick option from list
* Team (interesting one, as people can also make new teams)
class SignupQuestion
--> SignupQuestionDiet, SignupQuestionInsurance, SignupQuestionOption ....
Questions differentiate through enum question_type
Questions can have parameters like Option
--------------
Every SignupQuestionXYZ has a SignupAnswerXYZ
An activity consists of:
* usual event stuff, email, max_attendees, ....
* a slug!
* dict of SignupQuestions
* list of QuestionOrder
A signup then consists of:
* ActivityRef (what activity)
* EntityRef (who signed up)
* optional payment_id
* optional is_reserve
* optional confirmed (true if paid)
* dict of SignupAnswers
--------------
When saving a signup, we check:
* get activity
* is person allowed to sign up?
* do we have enough signup answers
* for every answer: does answer validate type of question?

View File

@ -0,0 +1,14 @@
from decimal import Decimal
from typing import Annotated
from pydantic import BaseModel, Field
class ActivityPaymentInfo(BaseModel):
contribution: Annotated[
Decimal, Field(decimal_places=2, ge=0)
]
nnv_contribution: Annotated[
Decimal | None, Field(None, decimal_places=2, ge=0)
] = None

View File

@ -11,7 +11,9 @@ CollectionName = Literal[
"entity", "entity",
"committee", "committee",
"committee_edition", "committee_edition",
"guild" "guild",
"signup",
"event"
] ]
C = TypeVar("C", bound=str) C = TypeVar("C", bound=str)
@ -47,3 +49,13 @@ CommitteeEditionRefList = RefList[Literal["committee_edition"]]
GuildRef = Ref[Literal["guild"]] GuildRef = Ref[Literal["guild"]]
GuildRefList = RefList[Literal["guild"]] GuildRefList = RefList[Literal["guild"]]
SignupRef = Ref[Literal["signup"]]
SignupRefList = RefList[Literal["signup"]]
CommunicationRef = Ref[Literal["event"]]
CommunicationRefList = Ref[Literal["event"]]
# Activities are stored in the collection of communications
ActivityRef = CommunicationRef
ActivityRefList = CommunicationRefList

View File

@ -0,0 +1,8 @@
from pydantic import BaseModel, ConfigDict
from fiber_package.signup.enums import FormQuestionCategory
class AnswerBase(BaseModel):
model_config = ConfigDict(extra='allow')
category: FormQuestionCategory
question_name: str

View File

@ -0,0 +1,9 @@
from typing import Literal
from .answer_base import AnswerBase
from fiber_package.signup.enums import FormElementCategory
class AnswerCheckmarks(AnswerBase):
category: Literal[FormElementCategory.checkmarks]
answer_list: list[bool]

View File

@ -0,0 +1,9 @@
from typing import Literal
from .answer_base import AnswerBase
from fiber_package.signup.enums import FormElementCategory
class AnswerOpenQuestions(AnswerBase):
category: Literal[FormElementCategory.open_questions]
answer_list: list[str]

View File

@ -0,0 +1,11 @@
from typing import Annotated
from pydantic import Field
from .answer_open_questions import AnswerOpenQuestions
from .answer_checkmarks import AnswerCheckmarks
AnswerUnion = Annotated[
AnswerOpenQuestions | AnswerCheckmarks,
Field(discriminator="category")
]

View File

@ -0,0 +1,11 @@
from .form_element import FormElement
from fiber_package.signup.enums import FormQuestionCategory
class FormBaseQuestion(FormElement):
category: FormQuestionCategory
required: bool
title: str | None = None
pretext: str | None = None
posttext: str | None = None

View File

@ -0,0 +1,5 @@
from .form_element import FormElement
from fiber_package.signup.enums import FormTextCategory
class FormBaseText(FormElement):
category: FormTextCategory

View File

@ -0,0 +1,29 @@
from typing import Literal
from pydantic import BaseModel
from .form_base_question import FormBaseQuestion
from fiber_package.signup.enums import FormElementCategory
from fiber_package.signup.answer.answer_checkmarks import AnswerCheckmarks
class CheckmarkQuestion(BaseModel):
question_en: str
question_nl: str
default: bool = False
class FormCheckmarks(FormBaseQuestion):
category: Literal[FormElementCategory.checkmarks]
questions: list[CheckmarkQuestion]
def validate_answer(self, answer: AnswerCheckmarks) -> None:
if answer.question_name != self.name:
raise ValueError("Answer question name does not match.")
answer_list = answer.answer_list
if len(answer_list) != len(self.questions):
raise ValueError("Length of answer question list does not match.")

View File

@ -0,0 +1,8 @@
from pydantic import BaseModel, ConfigDict
from fiber_package.signup.enums import FormElementCategory
class FormElement(BaseModel):
model_config = ConfigDict(extra='allow')
category: FormElementCategory
name: str

View File

@ -0,0 +1,34 @@
from typing import Literal
from pydantic import BaseModel
from .form_base_question import FormBaseQuestion
from fiber_package.signup.enums import FormElementCategory
from fiber_package.signup.answer.answer_open_questions import AnswerOpenQuestions
class OpenQuestion(BaseModel):
question_en: str
question_nl: str
explanation_en: str
explanation_nl: str
placeholder_en: str = ""
placeholder_nl: str = ""
class FormOpenQuestions(FormBaseQuestion):
category: Literal[FormElementCategory.open_questions]
questions: list[OpenQuestion]
def validate_answer(self, answer: AnswerOpenQuestions) -> None:
if answer.question_name != self.name:
raise ValueError("Answer question name does not match.")
answer_list = answer.answer_list
if len(answer_list) != len(self.questions):
raise ValueError("Length of answer question list does not match.")

View File

@ -0,0 +1,9 @@
from typing import Literal
from .form_element import FormElement
from fiber_package.signup.enums import FormElementCategory
class FormText(FormElement):
category: Literal[FormElementCategory.text_piece]
text: str

View File

@ -0,0 +1,9 @@
from typing import Literal
from .form_element import FormElement
from fiber_package.signup.enums import FormElementCategory
class FormTitle(FormElement):
category: Literal[FormElementCategory.title_text]
title: str

View File

@ -0,0 +1,23 @@
from typing import Annotated
from pydantic import Field
from .form_open_questions import FormOpenQuestions
from .form_checkmarks import FormCheckmarks
from .form_text import FormText
from .form_title import FormTitle
FormTextUnion = Annotated[
FormText | FormTitle,
Field(discriminator="category")
]
FormQuestionUnion = Annotated[
FormOpenQuestions | FormCheckmarks,
Field(discriminator="category")
]
FormElementUnion = Annotated[
FormTextUnion | FormQuestionUnion,
Field(discriminator="category")
]

View File

@ -0,0 +1,24 @@
from enum import StrEnum
from typing import Literal, get_args
'''
This category is used to differentiate types of form elements
'''
class FormElementCategory(StrEnum):
text_piece = "text_piece"
title_text = "title_text"
open_questions = "open_questions"
checkmarks = "checkmarks"
FormQuestionCategory = Literal[
FormElementCategory.open_questions,
FormElementCategory.checkmarks
]
FormTextCategory = Literal[
FormElementCategory.text_piece,
FormElementCategory.title_text
]
def is_question_category(category: FormElementCategory) -> bool:
return category in get_args(FormQuestionCategory)

View File

@ -0,0 +1,13 @@
In order to add a new question type you need to:
1) Make a new tag in enums.py
2) add it to the list of question tags
3) Create the FormXXX in element
4) Create the AnswerXXX in answer
5) Add it to the answer union type in answer.types
6) Add it to the question union type in element.types
Tip: run ./pre-commit to run the typechecker to see if you got your imports right!
7) Add it to fiber-django
6a) Add it to the process_question function event/util.py
6b) Add it to core/templates/form
6c) Add it to the form page (currently dlfsite/templates/testing/form.html)

View File

@ -0,0 +1,65 @@
Felt has sjablonen, stored in config
sjablonen.(name) has fields:
* naam_nl = default name
* naam_en = ...
* info = used in the sjabloon overview
* types (array_keys ???)
* parameters = dict of parameters & type
* parametes_beschrijving = description of parameters
* parameters_sample = default values of all parameters
On /activiteiten/bewerk we have a list of if statements
It groups the answers by type
* choises, feedback, teams, dinner, shirtsizes, nnv,
* public transport, reserve list, signups list
* activites store their "types" as bitmask (1 = medisch, 2 = thuisfront ...)
* stored in models\Parameter
* list of parameter datatypes,
Why is everything so builtin ?! Crazy.
List of question types:
* medical --> alergies, meds, extra info
* thuisfront --> name contact, email contact, phone #1, phone #2
* shirtsize --> pick enum from XXS, XS, S, M, L, XL, XXL
* identification --> document type, document number, expiry date, ...
* nnv --> state (notmember, later, member) & nnv number
* food --> joins for the meal (true/false), diet (text), vegetarian (t/f)
* public transport --> enum (no, week-only, weekend-only)
* travel / zorgverzekering --> verzekeraar, polisnummer, noodnummer, type (zorg of reis)
* questions --> parameter_id, slot, answer
* choices --> parameter_id, slot, choice
* feedback --> feedabck (str)
* team --> teamname (str)
omg it is TERRIBLE. In Silk every sjabloon has its OWN template ?!
so much work yikes
Example for the "Studiereis" sjabloon:
* Some text (incl deadline) --> we can refer to variables and use HTML!
* Header (your signup is coupled to your current account!)
* Health insurance (+ text)
* Medical information (+ text)
* Home stayers (+text & checkmark about International Office)
* Identification (+text)
* NNV (+text)
* T-shirt
* OV
* Feedback
Pattern:
* (title) (prefix text) item (suffix text)
Then possibly also:
* title-items & text-items
-------------
Ideas:
* Modular system!
* Create template activities (by creating activity, store as template & then delete?)
* Combine complicated questions into custom question (see all above)

View File

@ -0,0 +1,16 @@
from typing import Literal
from fiber_package.base import FiberBaseModel
from fiber_package.refs import EntityRef, ActivityRef
from fiber_package.signup.answer.types import AnswerUnion
class SignupModel(FiberBaseModel):
collection_name: Literal["signup"] = "signup"
attendee: EntityRef
activity: ActivityRef
answers: list[AnswerUnion]
# TODO: signed_up, is_reserve, payment, confirmed, ....

View File

@ -0,0 +1,18 @@
from fiber_package.signup.element.form_base_question import FormBaseQuestion
from fiber_package.signup.element.form_open_questions import FormOpenQuestions
from fiber_package.signup.answer.answer_base import AnswerBase
from fiber_package.signup.enums import FormElementCategory
from fiber_package.signup.answer.answer_open_questions import AnswerOpenQuestions
def validate_answer(question: FormBaseQuestion, answer: AnswerBase) -> None:
if question.category != answer.category:
raise ValueError("Question and answer category do not match!")
if question.category == FormElementCategory.open_questions:
question_val = FormOpenQuestions.model_validate(question.model_dump())
answer_val = AnswerOpenQuestions.model_validate(answer.model_dump())
question_val.validate_answer(answer_val)
# TODO: implement