From 017db0b3258de21fc35d8990cf3c3937608afd32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20Bl=C3=A5man?= Date: Mon, 16 Feb 2026 18:07:48 +0000 Subject: [PATCH 01/13] Ugly candidation csv route (will fix) --- routes/candidate_router.py | 79 +++++++++++++++++++++++++++++++++++++- 1 file changed, 78 insertions(+), 1 deletion(-) diff --git a/routes/candidate_router.py b/routes/candidate_router.py index a99a8369..67615657 100644 --- a/routes/candidate_router.py +++ b/routes/candidate_router.py @@ -1,7 +1,11 @@ +from io import StringIO from typing import Annotated -from fastapi import APIRouter, HTTPException, status +from fastapi import APIRouter, HTTPException, Response, status from datetime import datetime, timezone +from fastapi.responses import StreamingResponse +import pandas as pd + from api_schemas.candidate_schema import CandidateRead, CandidatePostRead from database import DB_dependency from db_models.candidate_model import Candidate_DB @@ -42,6 +46,79 @@ def get_all_sub_election_candidates(sub_election_id: int, db: DB_dependency): return candidates +@candidate_router.get("/election/{election_id}/csv", dependencies=[Permission.require("view", "Election")]) +def get_all_election_candidations(election_id: int, db: DB_dependency): + election = db.query(Election_DB).filter(Election_DB.election_id == election_id).one_or_none() + if election is None: + return Response(status_code=404) + + first_names: list[str] = [] + last_names: list[str] = [] + stil_ids: list[str | None] = [] + emails: list[str] = [] + post_names: list[str] = [] + + for se in election.sub_elections or []: + for c in se.candidations: + user = c.candidate.user + first_names.append(user.first_name) + last_names.append(user.last_name) + stil_ids.append(user.stil_id) + emails.append(user.email) + post_names.append(c.post.name_sv) + + d = { # type: ignore + "Förnamn": first_names, + "Efternamn": last_names, + "Stil-ID": stil_ids, + "Mailadress": emails, + "Post": post_names, + } + + df = pd.DataFrame(data=d) + csv_file = StringIO() + df.to_csv(csv_file, index=False) + response = StreamingResponse(iter([csv_file.getvalue()]), media_type="text/csv") + response.headers["Content-Disposition"] = "attachment; filename=candidations.csv" + return response + + +@candidate_router.get("/sub-election/{sub_election_id}/csv", dependencies=[Permission.require("view", "Election")]) +def get_all_sub_election_candidations(sub_election_id: int, db: DB_dependency): + sub_election = db.query(SubElection_DB).filter(SubElection_DB.sub_election_id == sub_election_id).one_or_none() + if sub_election is None: + return Response(status_code=404) + + first_names: list[str] = [] + last_names: list[str] = [] + stil_ids: list[str | None] = [] + emails: list[str] = [] + post_names: list[str] = [] + + for c in sub_election.candidations: + user = c.candidate.user + first_names.append(user.first_name) + last_names.append(user.last_name) + stil_ids.append(user.stil_id) + emails.append(user.email) + post_names.append(c.post.name_sv) + + d = { # type: ignore + "Förnamn": first_names, + "Efternamn": last_names, + "Stil-ID": stil_ids, + "E-post": emails, + "Post": post_names, + } + + df = pd.DataFrame(data=d) + csv_file = StringIO() + df.to_csv(csv_file, index=False) + response = StreamingResponse(iter([csv_file.getvalue()]), media_type="text/csv") + response.headers["Content-Disposition"] = "attachment; filename=candidations.csv" + return response + + @candidate_router.get( "/my-candidations/{election_id}", response_model=list[CandidatePostRead], dependencies=[Permission.member()] ) From e0c1e3007973ffa0f2849ccea427d3e355ef02f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20Bl=C3=A5man?= Date: Fri, 20 Feb 2026 23:21:03 +0000 Subject: [PATCH 02/13] Generalized candidates CSV solution --- api_schemas/csv_schemas/base_csv_schema.py | 13 +++ .../csv_schemas/candidate_csv_schema.py | 18 +++++ helpers/csv_response_factory.py | 53 ++++++++++++ routes/candidate_router.py | 80 ++++--------------- 4 files changed, 99 insertions(+), 65 deletions(-) create mode 100644 api_schemas/csv_schemas/base_csv_schema.py create mode 100644 api_schemas/csv_schemas/candidate_csv_schema.py create mode 100644 helpers/csv_response_factory.py diff --git a/api_schemas/csv_schemas/base_csv_schema.py b/api_schemas/csv_schemas/base_csv_schema.py new file mode 100644 index 00000000..6b3cc1dd --- /dev/null +++ b/api_schemas/csv_schemas/base_csv_schema.py @@ -0,0 +1,13 @@ +from typing import Annotated + +from pydantic import BaseModel, ConfigDict, Field + + +class BaseCsvSchema(BaseModel): + model_config = ConfigDict(from_attributes=True) + + __column_order__: Annotated[list[str] | None, Field(exclude=True)] = None + + +def CsvField(name: str | None = None, exclude: bool = False): + return Field(serialization_alias=name, exclude=exclude) diff --git a/api_schemas/csv_schemas/candidate_csv_schema.py b/api_schemas/csv_schemas/candidate_csv_schema.py new file mode 100644 index 00000000..d021119c --- /dev/null +++ b/api_schemas/csv_schemas/candidate_csv_schema.py @@ -0,0 +1,18 @@ +from pydantic import computed_field +from api_schemas.csv_schemas.base_csv_schema import BaseCsvSchema, CsvField + + +class CandidatesCsvSchema(BaseCsvSchema): + __column_order__ = ["stil_id", "name", "email", "post_name", "council_name"] + + first_name: str = CsvField("Förnamn", exclude=True) + last_name: str = CsvField("Efternamn", exclude=True) + stil_id: str | None = CsvField("Stil-ID") + email: str = CsvField("E-postadress") + post_name: str = CsvField("Post") + council_name: str = CsvField("Utskott") + + @computed_field(alias="Namn", return_type=str) + @property + def name(self): + return f"{self.first_name} {self.last_name}" diff --git a/helpers/csv_response_factory.py b/helpers/csv_response_factory.py new file mode 100644 index 00000000..6a811ddc --- /dev/null +++ b/helpers/csv_response_factory.py @@ -0,0 +1,53 @@ +from io import StringIO +from typing import Generic, TypeVar + +from fastapi.responses import StreamingResponse +import pandas as pd + +from api_schemas.csv_schemas.base_csv_schema import BaseCsvSchema + + +T = TypeVar("T", bound=BaseCsvSchema) + + +class CsvResponseFactory(Generic[T]): + def __init__(self) -> None: + self.__columns: dict[str, list[str]] = {} + self.__length = 0 + + def append(self, row: T) -> None: + if self.__length == 0: + self.__pre_append_first(row) + + dump = row.model_dump(by_alias=True) + for k, v in dump.items(): + self.__columns[k].append(str(v)) + + self.__length += 1 + + def __pre_append_first(self, row: T) -> None: + model_fields = { + k: v.serialization_alias or v.alias or k + for k, v in filter(lambda t: not t[1].exclude, row.model_fields.items()) + } + model_computed_fields = {k: v.alias or k for k, v in row.model_computed_fields.items()} + mappings = {**model_fields, **model_computed_fields} + + order = row.__column_order__ + if not order: + order = list(mappings.keys()) + + for c in order: + self.__columns[mappings[c]] = [] + + def to_csv(self) -> StringIO: + df = pd.DataFrame(data=self.__columns) + csv_file = StringIO() + df.to_csv(csv_file, index=False) + return csv_file + + def to_response(self, filename: str = "data.csv") -> StreamingResponse: + csv_file = self.to_csv() + response = StreamingResponse(iter([csv_file.getvalue()]), media_type="text/csv") + response.headers["Content-Disposition"] = "attachment; filename={filename}" + return response diff --git a/routes/candidate_router.py b/routes/candidate_router.py index 67615657..973b38a8 100644 --- a/routes/candidate_router.py +++ b/routes/candidate_router.py @@ -1,12 +1,10 @@ -from io import StringIO from typing import Annotated from fastapi import APIRouter, HTTPException, Response, status from datetime import datetime, timezone -from fastapi.responses import StreamingResponse -import pandas as pd from api_schemas.candidate_schema import CandidateRead, CandidatePostRead +from api_schemas.csv_schemas.candidate_csv_schema import CandidatesCsvSchema from database import DB_dependency from db_models.candidate_model import Candidate_DB from db_models.candidate_post_model import Candidation_DB @@ -14,6 +12,7 @@ from db_models.sub_election_model import SubElection_DB from db_models.election_post_model import ElectionPost_DB from db_models.user_model import User_DB +from helpers.csv_response_factory import CsvResponseFactory from user.permission import Permission @@ -46,77 +45,28 @@ def get_all_sub_election_candidates(sub_election_id: int, db: DB_dependency): return candidates -@candidate_router.get("/election/{election_id}/csv", dependencies=[Permission.require("view", "Election")]) -def get_all_election_candidations(election_id: int, db: DB_dependency): - election = db.query(Election_DB).filter(Election_DB.election_id == election_id).one_or_none() - if election is None: - return Response(status_code=404) - - first_names: list[str] = [] - last_names: list[str] = [] - stil_ids: list[str | None] = [] - emails: list[str] = [] - post_names: list[str] = [] - - for se in election.sub_elections or []: - for c in se.candidations: - user = c.candidate.user - first_names.append(user.first_name) - last_names.append(user.last_name) - stil_ids.append(user.stil_id) - emails.append(user.email) - post_names.append(c.post.name_sv) - - d = { # type: ignore - "Förnamn": first_names, - "Efternamn": last_names, - "Stil-ID": stil_ids, - "Mailadress": emails, - "Post": post_names, - } - - df = pd.DataFrame(data=d) - csv_file = StringIO() - df.to_csv(csv_file, index=False) - response = StreamingResponse(iter([csv_file.getvalue()]), media_type="text/csv") - response.headers["Content-Disposition"] = "attachment; filename=candidations.csv" - return response - - @candidate_router.get("/sub-election/{sub_election_id}/csv", dependencies=[Permission.require("view", "Election")]) def get_all_sub_election_candidations(sub_election_id: int, db: DB_dependency): sub_election = db.query(SubElection_DB).filter(SubElection_DB.sub_election_id == sub_election_id).one_or_none() if sub_election is None: return Response(status_code=404) - first_names: list[str] = [] - last_names: list[str] = [] - stil_ids: list[str | None] = [] - emails: list[str] = [] - post_names: list[str] = [] + factory: CsvResponseFactory[CandidatesCsvSchema] = CsvResponseFactory() for c in sub_election.candidations: user = c.candidate.user - first_names.append(user.first_name) - last_names.append(user.last_name) - stil_ids.append(user.stil_id) - emails.append(user.email) - post_names.append(c.post.name_sv) - - d = { # type: ignore - "Förnamn": first_names, - "Efternamn": last_names, - "Stil-ID": stil_ids, - "E-post": emails, - "Post": post_names, - } - - df = pd.DataFrame(data=d) - csv_file = StringIO() - df.to_csv(csv_file, index=False) - response = StreamingResponse(iter([csv_file.getvalue()]), media_type="text/csv") - response.headers["Content-Disposition"] = "attachment; filename=candidations.csv" - return response + + row = CandidatesCsvSchema( + first_name=user.first_name, + last_name=user.last_name, + stil_id=user.stil_id, + email=user.email, + post_name=c.post.name_sv, + council_name=c.post.council.name_sv, + ) + factory.append(row) + + return factory.to_response("candidations.csv") @candidate_router.get( From 611aebe7d451259fe56064d9e56b786c9b348d3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20Bl=C3=A5man?= Date: Fri, 20 Feb 2026 23:32:23 +0000 Subject: [PATCH 03/13] Improved candidates CSV schema --- api_schemas/csv_schemas/base_csv_schema.py | 6 +++--- api_schemas/csv_schemas/candidate_csv_schema.py | 16 ++++++++-------- routes/candidate_router.py | 11 +---------- 3 files changed, 12 insertions(+), 21 deletions(-) diff --git a/api_schemas/csv_schemas/base_csv_schema.py b/api_schemas/csv_schemas/base_csv_schema.py index 6b3cc1dd..fbdd2ef9 100644 --- a/api_schemas/csv_schemas/base_csv_schema.py +++ b/api_schemas/csv_schemas/base_csv_schema.py @@ -1,6 +1,6 @@ from typing import Annotated -from pydantic import BaseModel, ConfigDict, Field +from pydantic import AliasChoices, AliasPath, BaseModel, ConfigDict, Field class BaseCsvSchema(BaseModel): @@ -9,5 +9,5 @@ class BaseCsvSchema(BaseModel): __column_order__: Annotated[list[str] | None, Field(exclude=True)] = None -def CsvField(name: str | None = None, exclude: bool = False): - return Field(serialization_alias=name, exclude=exclude) +def CsvField(name: str | None = None, from_path: str | AliasPath | AliasChoices | None = None, exclude: bool = False): + return Field(serialization_alias=name, validation_alias=from_path, exclude=exclude) diff --git a/api_schemas/csv_schemas/candidate_csv_schema.py b/api_schemas/csv_schemas/candidate_csv_schema.py index d021119c..1aecd3d8 100644 --- a/api_schemas/csv_schemas/candidate_csv_schema.py +++ b/api_schemas/csv_schemas/candidate_csv_schema.py @@ -1,16 +1,16 @@ -from pydantic import computed_field +from pydantic import AliasPath, computed_field from api_schemas.csv_schemas.base_csv_schema import BaseCsvSchema, CsvField class CandidatesCsvSchema(BaseCsvSchema): - __column_order__ = ["stil_id", "name", "email", "post_name", "council_name"] + __column_order__ = ["stil_id", "last_name", "first_name", "name", "email", "post_name", "council_name"] - first_name: str = CsvField("Förnamn", exclude=True) - last_name: str = CsvField("Efternamn", exclude=True) - stil_id: str | None = CsvField("Stil-ID") - email: str = CsvField("E-postadress") - post_name: str = CsvField("Post") - council_name: str = CsvField("Utskott") + first_name: str = CsvField("Förnamn", from_path=AliasPath("candidate", "user", "first_name")) + last_name: str = CsvField("Efternamn", from_path=AliasPath("candidate", "user", "last_name")) + stil_id: str | None = CsvField("Stil-ID", from_path=AliasPath("candidate", "user", "stil_id")) + email: str = CsvField("E-postadress", from_path=AliasPath("candidate", "user", "email")) + post_name: str = CsvField("Post", from_path=AliasPath("post", "name_sv")) + council_name: str = CsvField("Utskott", from_path=AliasPath("post", "council", "name_sv")) @computed_field(alias="Namn", return_type=str) @property diff --git a/routes/candidate_router.py b/routes/candidate_router.py index 973b38a8..73b6f738 100644 --- a/routes/candidate_router.py +++ b/routes/candidate_router.py @@ -54,16 +54,7 @@ def get_all_sub_election_candidations(sub_election_id: int, db: DB_dependency): factory: CsvResponseFactory[CandidatesCsvSchema] = CsvResponseFactory() for c in sub_election.candidations: - user = c.candidate.user - - row = CandidatesCsvSchema( - first_name=user.first_name, - last_name=user.last_name, - stil_id=user.stil_id, - email=user.email, - post_name=c.post.name_sv, - council_name=c.post.council.name_sv, - ) + row = CandidatesCsvSchema.model_validate(c) factory.append(row) return factory.to_response("candidations.csv") From 32a29d2b54693113bda3b03def8bf30dca56127e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20Bl=C3=A5man?= Date: Sat, 21 Feb 2026 00:00:11 +0000 Subject: [PATCH 04/13] Event csv now uses new system --- .../csv_schemas/candidate_csv_schema.py | 2 +- api_schemas/csv_schemas/event_csv_schema.py | 45 ++++++++++++++++++ routes/candidate_router.py | 2 +- routes/event_router.py | 47 +++---------------- 4 files changed, 54 insertions(+), 42 deletions(-) create mode 100644 api_schemas/csv_schemas/event_csv_schema.py diff --git a/api_schemas/csv_schemas/candidate_csv_schema.py b/api_schemas/csv_schemas/candidate_csv_schema.py index 1aecd3d8..9197a1f5 100644 --- a/api_schemas/csv_schemas/candidate_csv_schema.py +++ b/api_schemas/csv_schemas/candidate_csv_schema.py @@ -8,7 +8,7 @@ class CandidatesCsvSchema(BaseCsvSchema): first_name: str = CsvField("Förnamn", from_path=AliasPath("candidate", "user", "first_name")) last_name: str = CsvField("Efternamn", from_path=AliasPath("candidate", "user", "last_name")) stil_id: str | None = CsvField("Stil-ID", from_path=AliasPath("candidate", "user", "stil_id")) - email: str = CsvField("E-postadress", from_path=AliasPath("candidate", "user", "email")) + email: str = CsvField("E-post", from_path=AliasPath("candidate", "user", "email")) post_name: str = CsvField("Post", from_path=AliasPath("post", "name_sv")) council_name: str = CsvField("Utskott", from_path=AliasPath("post", "council", "name_sv")) diff --git a/api_schemas/csv_schemas/event_csv_schema.py b/api_schemas/csv_schemas/event_csv_schema.py new file mode 100644 index 00000000..01871df7 --- /dev/null +++ b/api_schemas/csv_schemas/event_csv_schema.py @@ -0,0 +1,45 @@ +from pydantic import AliasPath, computed_field +from api_schemas.csv_schemas.base_csv_schema import BaseCsvSchema, CsvField +from helpers.types import DRINK_PACKAGES + + +class EventCsvSchema(BaseCsvSchema): + __column_order__ = [ + "stil_id", + "last_name", + "first_name", + "name", + "email", + "food_prefs", + "drinkPackage", + "group_name", + "priority", + ] + + first_name: str = CsvField("Förnamn", from_path=AliasPath("user", "first_name")) + last_name: str = CsvField("Efternamn", from_path=AliasPath("user", "last_name")) + stil_id: str | None = CsvField("Stil-ID", from_path=AliasPath("user", "stil_id")) + email: str = CsvField("E-post", from_path=AliasPath("user", "email")) + standard_food_preferences: list[str] | None = CsvField( + from_path=AliasPath("user", "standard_food_preferences"), exclude=True + ) + other_food_preferences: str | None = CsvField(from_path=AliasPath("user", "other_food_preferences"), exclude=True) + drinkPackage: DRINK_PACKAGES = CsvField("Dryckespaket") + group_name: str | None = CsvField("Grupp") + priority: str = CsvField("Prioritet") + + @computed_field(alias="Namn", return_type=str) + @property + def name(self): + return f"{self.first_name} {self.last_name}" + + @computed_field(alias="Matpreferens", return_type=str) + @property + def food_prefs(self): + res: list[str] = [] + if self.standard_food_preferences: + res += self.standard_food_preferences + if self.other_food_preferences: + res.append(self.other_food_preferences) + + return ", ".join(res) diff --git a/routes/candidate_router.py b/routes/candidate_router.py index 73b6f738..416aa90b 100644 --- a/routes/candidate_router.py +++ b/routes/candidate_router.py @@ -49,7 +49,7 @@ def get_all_sub_election_candidates(sub_election_id: int, db: DB_dependency): def get_all_sub_election_candidations(sub_election_id: int, db: DB_dependency): sub_election = db.query(SubElection_DB).filter(SubElection_DB.sub_election_id == sub_election_id).one_or_none() if sub_election is None: - return Response(status_code=404) + raise HTTPException(404, detail="Sub election not found") factory: CsvResponseFactory[CandidatesCsvSchema] = CsvResponseFactory() diff --git a/routes/event_router.py b/routes/event_router.py index 5bbdd198..a04d342f 100644 --- a/routes/event_router.py +++ b/routes/event_router.py @@ -4,6 +4,7 @@ from fastapi import APIRouter, File, HTTPException, Response, UploadFile, status from fastapi.responses import FileResponse, StreamingResponse from psycopg import IntegrityError +from api_schemas.csv_schemas.event_csv_schema import EventCsvSchema from api_schemas.event_signup_schemas import EventSignupRead from api_schemas.tag_schema import EventTagRead from database import DB_dependency @@ -12,6 +13,7 @@ from db_models.event_user_model import EventUser_DB from db_models.user_model import User_DB from db_models.event_tag_model import EventTag_DB +from helpers.csv_response_factory import CsvResponseFactory from helpers.image_checker import validate_image from db_models.post_model import Post_DB from services.event_service import create_new_event, delete_event, update_event @@ -330,46 +332,11 @@ def get_event_csv(db: DB_dependency, event_id: int): event_users = event.event_users event_users.sort(key=lambda e_user: e_user.user.last_name) - names: list[str] = [] - telephone_numbers: list[str] = [] - email_addresses: list[str] = [] - food_preferences: list[str] = [] - drink_packages: list[str] = [] - groups: list[str] = [] - priorities: list[str] = [] + factory: CsvResponseFactory[EventCsvSchema] = CsvResponseFactory() for event_user in event_users: if event_user.confirmed_status is True: - user = event_user.user - names.append(f"{user.first_name} {user.last_name}") - telephone_numbers.append(user.telephone_number) - email_addresses.append(user.email) - if user.standard_food_preferences and user.other_food_preferences: - user_food_prefs = ", ".join(user.standard_food_preferences) + ", " + user.other_food_preferences - elif user.standard_food_preferences: - user_food_prefs = ", ".join(user.standard_food_preferences) - elif user.other_food_preferences: - user_food_prefs = user.other_food_preferences - else: - user_food_prefs = "" - food_preferences.append(user_food_prefs) - drink_packages.append(event_user.drinkPackage or "None") - groups.append(event_user.group_name or "") - priorities.append(event_user.priority) - - d = { - "Namn": names, - "Telefonnummer": telephone_numbers, - "E-post": email_addresses, - "Matpreferens": food_preferences, - "Dryckespaket": drink_packages, - "Grupp": groups, - "Prioritet": priorities, - } - - df = pd.DataFrame(data=d) - csv_file = StringIO() - df.to_csv(csv_file, index=False) - response = StreamingResponse(iter([csv_file.getvalue()]), media_type="text/csv") - response.headers["Content-Disposition"] = "attachment; filename=event.csv" - return response + row = EventCsvSchema.model_validate(event_user) + factory.append(row) + + return factory.to_response("event.csv") From a3c67adeb4ff0e778c29b5899091549c1700aab0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20Bl=C3=A5man?= Date: Sat, 21 Feb 2026 00:25:16 +0000 Subject: [PATCH 05/13] Changed event csv url to be more consistent with other csv url --- routes/event_router.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/routes/event_router.py b/routes/event_router.py index a04d342f..ce069ae9 100644 --- a/routes/event_router.py +++ b/routes/event_router.py @@ -1,8 +1,7 @@ from datetime import datetime -from io import StringIO import os from fastapi import APIRouter, File, HTTPException, Response, UploadFile, status -from fastapi.responses import FileResponse, StreamingResponse +from fastapi.responses import FileResponse from psycopg import IntegrityError from api_schemas.csv_schemas.event_csv_schema import EventCsvSchema from api_schemas.event_signup_schemas import EventSignupRead @@ -23,8 +22,6 @@ from pathlib import Path -import pandas as pd - event_router = APIRouter() @@ -322,7 +319,8 @@ def get_event_tags(db: DB_dependency, event_id: int): return event_tags -@event_router.get("/get-event-csv/{event_id}", dependencies=[Permission.require("manage", "Event")]) +@event_router.get("/get-event-csv/{event_id}", dependencies=[Permission.require("manage", "Event")], deprecated=True) +@event_router.get("/event-signups/confirmed/{event_id}/csv", dependencies=[Permission.require("manage", "Event")]) def get_event_csv(db: DB_dependency, event_id: int): event = db.query(Event_DB).filter(Event_DB.id == event_id).one_or_none() From 6d0e5eac9bbf35c71ac28c2729d9ae8b00944e38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20Bl=C3=A5man?= Date: Sat, 21 Feb 2026 00:36:35 +0000 Subject: [PATCH 06/13] EventCsvSchema -> EventUserCsvSchema --- .../{event_csv_schema.py => event_user_csv_schema.py} | 2 +- routes/event_router.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) rename api_schemas/csv_schemas/{event_csv_schema.py => event_user_csv_schema.py} (97%) diff --git a/api_schemas/csv_schemas/event_csv_schema.py b/api_schemas/csv_schemas/event_user_csv_schema.py similarity index 97% rename from api_schemas/csv_schemas/event_csv_schema.py rename to api_schemas/csv_schemas/event_user_csv_schema.py index 01871df7..ae6a1932 100644 --- a/api_schemas/csv_schemas/event_csv_schema.py +++ b/api_schemas/csv_schemas/event_user_csv_schema.py @@ -3,7 +3,7 @@ from helpers.types import DRINK_PACKAGES -class EventCsvSchema(BaseCsvSchema): +class EventUserCsvSchema(BaseCsvSchema): __column_order__ = [ "stil_id", "last_name", diff --git a/routes/event_router.py b/routes/event_router.py index ce069ae9..c480775f 100644 --- a/routes/event_router.py +++ b/routes/event_router.py @@ -3,7 +3,7 @@ from fastapi import APIRouter, File, HTTPException, Response, UploadFile, status from fastapi.responses import FileResponse from psycopg import IntegrityError -from api_schemas.csv_schemas.event_csv_schema import EventCsvSchema +from api_schemas.csv_schemas.event_user_csv_schema import EventUserCsvSchema from api_schemas.event_signup_schemas import EventSignupRead from api_schemas.tag_schema import EventTagRead from database import DB_dependency @@ -330,11 +330,11 @@ def get_event_csv(db: DB_dependency, event_id: int): event_users = event.event_users event_users.sort(key=lambda e_user: e_user.user.last_name) - factory: CsvResponseFactory[EventCsvSchema] = CsvResponseFactory() + factory: CsvResponseFactory[EventUserCsvSchema] = CsvResponseFactory() for event_user in event_users: if event_user.confirmed_status is True: - row = EventCsvSchema.model_validate(event_user) + row = EventUserCsvSchema.model_validate(event_user) factory.append(row) return factory.to_response("event.csv") From c2b73b7eef1c766a478276744c4918cc4bc4f9da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20Bl=C3=A5man?= Date: Sat, 21 Feb 2026 01:05:52 +0000 Subject: [PATCH 07/13] Function name improvement --- routes/candidate_router.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/routes/candidate_router.py b/routes/candidate_router.py index 416aa90b..d58ef1c5 100644 --- a/routes/candidate_router.py +++ b/routes/candidate_router.py @@ -46,7 +46,7 @@ def get_all_sub_election_candidates(sub_election_id: int, db: DB_dependency): @candidate_router.get("/sub-election/{sub_election_id}/csv", dependencies=[Permission.require("view", "Election")]) -def get_all_sub_election_candidations(sub_election_id: int, db: DB_dependency): +def get_all_sub_election_candidations_csv(sub_election_id: int, db: DB_dependency): sub_election = db.query(SubElection_DB).filter(SubElection_DB.sub_election_id == sub_election_id).one_or_none() if sub_election is None: raise HTTPException(404, detail="Sub election not found") From 666dc86e01e9561f671b2c24ee13b5a9580141b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20Bl=C3=A5man?= Date: Sat, 21 Feb 2026 11:58:27 +0000 Subject: [PATCH 08/13] Add docstring for CsvField --- api_schemas/csv_schemas/base_csv_schema.py | 1 + 1 file changed, 1 insertion(+) diff --git a/api_schemas/csv_schemas/base_csv_schema.py b/api_schemas/csv_schemas/base_csv_schema.py index fbdd2ef9..5e6fe731 100644 --- a/api_schemas/csv_schemas/base_csv_schema.py +++ b/api_schemas/csv_schemas/base_csv_schema.py @@ -10,4 +10,5 @@ class BaseCsvSchema(BaseModel): def CsvField(name: str | None = None, from_path: str | AliasPath | AliasChoices | None = None, exclude: bool = False): + """Wrapper for `Field` with `serialization_alias`, `validation_alias` and `exclude`. Can be exchanged for normal `Field` when more advanced settings are necessary.""" return Field(serialization_alias=name, validation_alias=from_path, exclude=exclude) From 4f191c4b0f0e18b0169eb9e630ff45c8f7b53030 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20Bl=C3=A5man?= Date: Sat, 21 Feb 2026 12:17:18 +0000 Subject: [PATCH 09/13] CsvResponseFactory fixes --- helpers/csv_response_factory.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/helpers/csv_response_factory.py b/helpers/csv_response_factory.py index 6a811ddc..ebcef13d 100644 --- a/helpers/csv_response_factory.py +++ b/helpers/csv_response_factory.py @@ -13,19 +13,17 @@ class CsvResponseFactory(Generic[T]): def __init__(self) -> None: self.__columns: dict[str, list[str]] = {} - self.__length = 0 + self.__headers_initialized = False def append(self, row: T) -> None: - if self.__length == 0: - self.__pre_append_first(row) + if not self.__headers_initialized: + self.__initialize_headers(row) dump = row.model_dump(by_alias=True) - for k, v in dump.items(): - self.__columns[k].append(str(v)) + for k in self.__columns.keys(): + self.__columns[k].append(str(dump[k])) - self.__length += 1 - - def __pre_append_first(self, row: T) -> None: + def __initialize_headers(self, row: T) -> None: model_fields = { k: v.serialization_alias or v.alias or k for k, v in filter(lambda t: not t[1].exclude, row.model_fields.items()) @@ -40,6 +38,8 @@ def __pre_append_first(self, row: T) -> None: for c in order: self.__columns[mappings[c]] = [] + self.__headers_initialized = True + def to_csv(self) -> StringIO: df = pd.DataFrame(data=self.__columns) csv_file = StringIO() @@ -49,5 +49,5 @@ def to_csv(self) -> StringIO: def to_response(self, filename: str = "data.csv") -> StreamingResponse: csv_file = self.to_csv() response = StreamingResponse(iter([csv_file.getvalue()]), media_type="text/csv") - response.headers["Content-Disposition"] = "attachment; filename={filename}" + response.headers["Content-Disposition"] = f"attachment; filename={filename}" return response From a64f0d7e02b624cd67452ec448f356be969c6723 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20Bl=C3=A5man?= Date: Sat, 21 Feb 2026 12:24:37 +0000 Subject: [PATCH 10/13] Translate drink package for event user csv --- .../csv_schemas/event_user_csv_schema.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/api_schemas/csv_schemas/event_user_csv_schema.py b/api_schemas/csv_schemas/event_user_csv_schema.py index ae6a1932..ecb6182f 100644 --- a/api_schemas/csv_schemas/event_user_csv_schema.py +++ b/api_schemas/csv_schemas/event_user_csv_schema.py @@ -1,4 +1,4 @@ -from pydantic import AliasPath, computed_field +from pydantic import AliasPath, computed_field, field_validator, validator from api_schemas.csv_schemas.base_csv_schema import BaseCsvSchema, CsvField from helpers.types import DRINK_PACKAGES @@ -24,7 +24,7 @@ class EventUserCsvSchema(BaseCsvSchema): from_path=AliasPath("user", "standard_food_preferences"), exclude=True ) other_food_preferences: str | None = CsvField(from_path=AliasPath("user", "other_food_preferences"), exclude=True) - drinkPackage: DRINK_PACKAGES = CsvField("Dryckespaket") + drinkPackage: str = CsvField("Dryckespaket") group_name: str | None = CsvField("Grupp") priority: str = CsvField("Prioritet") @@ -43,3 +43,16 @@ def food_prefs(self): res.append(self.other_food_preferences) return ", ".join(res) + + @field_validator("drinkPackage", mode="before") + @classmethod + def validate_drink_package(cls, value: DRINK_PACKAGES) -> str: + match value: + case "Alcohol": + return "Alkohol" + case "AlcoholFree": + return "Alkoholfritt" + case "None": + return "Inget" + case _: + return value From 960b69eb3752d02f1e98f92cf65269b8a3fd4162 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20Bl=C3=A5man?= Date: Sat, 21 Feb 2026 15:56:31 +0000 Subject: [PATCH 11/13] Added none string --- helpers/csv_response_factory.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/helpers/csv_response_factory.py b/helpers/csv_response_factory.py index ebcef13d..a099c5a8 100644 --- a/helpers/csv_response_factory.py +++ b/helpers/csv_response_factory.py @@ -11,9 +11,10 @@ class CsvResponseFactory(Generic[T]): - def __init__(self) -> None: + def __init__(self, none_str: str = "") -> None: self.__columns: dict[str, list[str]] = {} self.__headers_initialized = False + self.none_str = none_str def append(self, row: T) -> None: if not self.__headers_initialized: @@ -21,7 +22,7 @@ def append(self, row: T) -> None: dump = row.model_dump(by_alias=True) for k in self.__columns.keys(): - self.__columns[k].append(str(dump[k])) + self.__columns[k].append(str(dump[k] or self.none_str)) def __initialize_headers(self, row: T) -> None: model_fields = { From 29b08ca1bf1e7671bd72e1383e90bd37ddca116b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20Bl=C3=A5man?= Date: Sat, 21 Feb 2026 22:34:49 +0000 Subject: [PATCH 12/13] csv_response_factory slightly more robust --- helpers/csv_response_factory.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/helpers/csv_response_factory.py b/helpers/csv_response_factory.py index a099c5a8..6d397e12 100644 --- a/helpers/csv_response_factory.py +++ b/helpers/csv_response_factory.py @@ -22,7 +22,7 @@ def append(self, row: T) -> None: dump = row.model_dump(by_alias=True) for k in self.__columns.keys(): - self.__columns[k].append(str(dump[k] or self.none_str)) + self.__columns[k].append(str(dump.get(k) or self.none_str)) def __initialize_headers(self, row: T) -> None: model_fields = { @@ -37,6 +37,8 @@ def __initialize_headers(self, row: T) -> None: order = list(mappings.keys()) for c in order: + if c not in mappings: + continue self.__columns[mappings[c]] = [] self.__headers_initialized = True From 30b88e0364303f1924d9d1965c7c125087519588 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20Bl=C3=A5man?= Date: Mon, 23 Feb 2026 16:30:22 +0000 Subject: [PATCH 13/13] Remove deprecated route --- routes/event_router.py | 1 - 1 file changed, 1 deletion(-) diff --git a/routes/event_router.py b/routes/event_router.py index c480775f..dcca34f9 100644 --- a/routes/event_router.py +++ b/routes/event_router.py @@ -319,7 +319,6 @@ def get_event_tags(db: DB_dependency, event_id: int): return event_tags -@event_router.get("/get-event-csv/{event_id}", dependencies=[Permission.require("manage", "Event")], deprecated=True) @event_router.get("/event-signups/confirmed/{event_id}/csv", dependencies=[Permission.require("manage", "Event")]) def get_event_csv(db: DB_dependency, event_id: int): event = db.query(Event_DB).filter(Event_DB.id == event_id).one_or_none()