diff --git a/src/fiber_package/entity/person.py b/src/fiber_package/entity/person.py index 4d05c34..eeabbeb 100644 --- a/src/fiber_package/entity/person.py +++ b/src/fiber_package/entity/person.py @@ -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 datetime import date @@ -31,7 +31,7 @@ class PersonModel(FiberBaseModel): if self.date_of_birth is None: return False if ref_date is None: - ref_data = date.today() + ref_date = date.today() age = ( ref_date.year - self.date_of_birth.year - ( (ref_date.month, ref_date.day) < (self.date_of_birth.month, self.date_of_birth.day) ) diff --git a/src/fiber_package/event/activity.py b/src/fiber_package/event/activity.py new file mode 100644 index 0000000..c0aeae7 --- /dev/null +++ b/src/fiber_package/event/activity.py @@ -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.") diff --git a/src/fiber_package/event/communication.py b/src/fiber_package/event/communication.py new file mode 100644 index 0000000..d5b17ee --- /dev/null +++ b/src/fiber_package/event/communication.py @@ -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 diff --git a/src/fiber_package/event/enums.py b/src/fiber_package/event/enums.py new file mode 100644 index 0000000..84682d2 --- /dev/null +++ b/src/fiber_package/event/enums.py @@ -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" diff --git a/src/fiber_package/event/notes.txt b/src/fiber_package/event/notes.txt new file mode 100644 index 0000000..f19315c --- /dev/null +++ b/src/fiber_package/event/notes.txt @@ -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? + + + diff --git a/src/fiber_package/event/submodels.py b/src/fiber_package/event/submodels.py new file mode 100644 index 0000000..ffc6f30 --- /dev/null +++ b/src/fiber_package/event/submodels.py @@ -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 + + diff --git a/src/fiber_package/refs.py b/src/fiber_package/refs.py index 6032df2..e74312d 100644 --- a/src/fiber_package/refs.py +++ b/src/fiber_package/refs.py @@ -11,7 +11,9 @@ CollectionName = Literal[ "entity", "committee", "committee_edition", - "guild" + "guild", + "signup", + "event" ] C = TypeVar("C", bound=str) @@ -47,3 +49,13 @@ CommitteeEditionRefList = RefList[Literal["committee_edition"]] GuildRef = Ref[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 diff --git a/src/fiber_package/signup/answer/answer_base.py b/src/fiber_package/signup/answer/answer_base.py new file mode 100644 index 0000000..533793a --- /dev/null +++ b/src/fiber_package/signup/answer/answer_base.py @@ -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 diff --git a/src/fiber_package/signup/answer/answer_checkmarks.py b/src/fiber_package/signup/answer/answer_checkmarks.py new file mode 100644 index 0000000..95c3842 --- /dev/null +++ b/src/fiber_package/signup/answer/answer_checkmarks.py @@ -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] diff --git a/src/fiber_package/signup/answer/answer_open_questions.py b/src/fiber_package/signup/answer/answer_open_questions.py new file mode 100644 index 0000000..b399dbb --- /dev/null +++ b/src/fiber_package/signup/answer/answer_open_questions.py @@ -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] diff --git a/src/fiber_package/signup/answer/types.py b/src/fiber_package/signup/answer/types.py new file mode 100644 index 0000000..8d31d00 --- /dev/null +++ b/src/fiber_package/signup/answer/types.py @@ -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") +] diff --git a/src/fiber_package/signup/element/form_base_question.py b/src/fiber_package/signup/element/form_base_question.py new file mode 100644 index 0000000..c0c1942 --- /dev/null +++ b/src/fiber_package/signup/element/form_base_question.py @@ -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 diff --git a/src/fiber_package/signup/element/form_base_text.py b/src/fiber_package/signup/element/form_base_text.py new file mode 100644 index 0000000..1cffa93 --- /dev/null +++ b/src/fiber_package/signup/element/form_base_text.py @@ -0,0 +1,5 @@ +from .form_element import FormElement +from fiber_package.signup.enums import FormTextCategory + +class FormBaseText(FormElement): + category: FormTextCategory diff --git a/src/fiber_package/signup/element/form_checkmarks.py b/src/fiber_package/signup/element/form_checkmarks.py new file mode 100644 index 0000000..d0af93c --- /dev/null +++ b/src/fiber_package/signup/element/form_checkmarks.py @@ -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.") + + + + diff --git a/src/fiber_package/signup/element/form_element.py b/src/fiber_package/signup/element/form_element.py new file mode 100644 index 0000000..b68be56 --- /dev/null +++ b/src/fiber_package/signup/element/form_element.py @@ -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 diff --git a/src/fiber_package/signup/element/form_open_questions.py b/src/fiber_package/signup/element/form_open_questions.py new file mode 100644 index 0000000..c0429a8 --- /dev/null +++ b/src/fiber_package/signup/element/form_open_questions.py @@ -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.") + + + + diff --git a/src/fiber_package/signup/element/form_text.py b/src/fiber_package/signup/element/form_text.py new file mode 100644 index 0000000..991e42b --- /dev/null +++ b/src/fiber_package/signup/element/form_text.py @@ -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 diff --git a/src/fiber_package/signup/element/form_title.py b/src/fiber_package/signup/element/form_title.py new file mode 100644 index 0000000..d300c64 --- /dev/null +++ b/src/fiber_package/signup/element/form_title.py @@ -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 diff --git a/src/fiber_package/signup/element/types.py b/src/fiber_package/signup/element/types.py new file mode 100644 index 0000000..76c8a36 --- /dev/null +++ b/src/fiber_package/signup/element/types.py @@ -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") +] diff --git a/src/fiber_package/signup/enums.py b/src/fiber_package/signup/enums.py new file mode 100644 index 0000000..5cb289d --- /dev/null +++ b/src/fiber_package/signup/enums.py @@ -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) diff --git a/src/fiber_package/signup/howto-add-new-question.txt b/src/fiber_package/signup/howto-add-new-question.txt new file mode 100644 index 0000000..7c931ae --- /dev/null +++ b/src/fiber_package/signup/howto-add-new-question.txt @@ -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) \ No newline at end of file diff --git a/src/fiber_package/signup/notes.txt b/src/fiber_package/signup/notes.txt new file mode 100644 index 0000000..7df7049 --- /dev/null +++ b/src/fiber_package/signup/notes.txt @@ -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) diff --git a/src/fiber_package/signup/signup.py b/src/fiber_package/signup/signup.py new file mode 100644 index 0000000..8690685 --- /dev/null +++ b/src/fiber_package/signup/signup.py @@ -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, .... diff --git a/src/fiber_package/signup/util.py b/src/fiber_package/signup/util.py new file mode 100644 index 0000000..91d037f --- /dev/null +++ b/src/fiber_package/signup/util.py @@ -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