diff --git a/flaschengeist/plugins/schedule/__init__.py b/flaschengeist/plugins/events/__init__.py similarity index 96% rename from flaschengeist/plugins/schedule/__init__.py rename to flaschengeist/plugins/events/__init__.py index a2e3ec0..5b1b772 100644 --- a/flaschengeist/plugins/schedule/__init__.py +++ b/flaschengeist/plugins/events/__init__.py @@ -17,10 +17,10 @@ from . import event_controller, permissions from . import models from ...utils.HTTP import no_content -schedule_bp = Blueprint("schedule", __name__, url_prefix="/schedule") +schedule_bp = Blueprint("events", __name__, url_prefix="/schedule") -class SchedulePlugin(Plugin): +class EventPlugin(Plugin): models = models def __init__(self, config): @@ -268,12 +268,20 @@ def create_event(current_session): except (NotFound, ValueError): raise BadRequest("Invalid parameter") + recurrence_rule = None + if "recurrence_rule" in data: + recurrence_rule = event_controller.create_recurrence(data=data["recurrence_rule"]) event = event_controller.create_event( - start=start, end=end, event_type=event_type, description=data.get("description", None) + start=start, + end=end, + event_type=event_type, + description=data.get("description", None), + recurrence_rule=recurrence_rule, ) if "jobs" in data: for job in data["jobs"]: _add_job(event, job) + return jsonify(event) @@ -297,6 +305,8 @@ def modify_event(event_id, current_session): data = request.get_json() if "start" in data: event.start = from_iso_format(data["start"]) + if "end" in data: + event.end = from_iso_format(data["end"]) if "description" in data: event.description = data["description"] if "type" in data: diff --git a/flaschengeist/plugins/schedule/event_controller.py b/flaschengeist/plugins/events/event_controller.py similarity index 76% rename from flaschengeist/plugins/schedule/event_controller.py rename to flaschengeist/plugins/events/event_controller.py index dbc829d..3432e29 100644 --- a/flaschengeist/plugins/schedule/event_controller.py +++ b/flaschengeist/plugins/events/event_controller.py @@ -3,7 +3,8 @@ from sqlalchemy.exc import IntegrityError from flaschengeist import logger from flaschengeist.database import db -from flaschengeist.plugins.schedule.models import EventType, Event, Job, JobType, Service +from flaschengeist.plugins.events.models import EventType, Event, Job, JobType, Service, RecurrenceRule +from flaschengeist.utils.datetime import from_iso_format def update(): @@ -97,7 +98,7 @@ def delete_job_type(name): raise BadRequest("Type still in use") -def get_event(event_id): +def get_event(event_id) -> Event: event = Event.query.get(event_id) if event is None: raise NotFound @@ -128,11 +129,15 @@ def delete_event(event_id): db.session.commit() -def create_event(event_type, start, end=None, jobs=[], description=None): +def create_event(event_type, start, end=None, jobs=[], description=None, recurrence_rule=None): try: logger.debug(event_type) - event = Event(start=start, end=end, description=description, type=event_type, jobs=jobs) + event = Event( + start=start, end=end, description=description, type=event_type, jobs=jobs, recurrence_rule=recurrence_rule + ) db.session.add(event) + if recurrence_rule is not None: + event = Event(start=start, end=end, description=description, type=event_type, jobs=jobs, template_=event) db.session.commit() return event except IntegrityError: @@ -141,7 +146,7 @@ def create_event(event_type, start, end=None, jobs=[], description=None): def get_job(job_slot_id, event_id): - js = Job.query.filter(Job.id == job_slot_id).filter(Job._event_id == event_id).one_or_none() + js = Job.query.filter(Job.id == job_slot_id).filter(Job.event_id_ == event_id).one_or_none() if js is None: raise NotFound return js @@ -168,7 +173,7 @@ def delete_job(job: Job): def assign_to_job(job: Job, user, value): - service = Service.query.get((job.id, user._id)) + service = Service.query.get((job.id, user.id_)) if value < 0: if not service: raise BadRequest @@ -180,3 +185,18 @@ def assign_to_job(job: Job, user, value): service = Service(user_=user, value=value, job_=job) db.session.add(service) db.session.commit() + + +def create_recurrence(data=None, count=None, end_date=None, frequency=None, interval=None): + if data is not None: + if "frequency" not in data: + raise BadRequest("Missing POST parameter") + frequency = data["frequency"] + if "end_date" in data: + end_date = from_iso_format(data["end_date"]) + if "count" in data: + count = data["count"] + if "interval" in data: + interval = data["interval"] + recurrence = RecurrenceRule(frequency=frequency, end_date=end_date, count=count, interval=interval) + db.session.add(recurrence) diff --git a/flaschengeist/plugins/events/models.py b/flaschengeist/plugins/events/models.py new file mode 100644 index 0000000..d645d7c --- /dev/null +++ b/flaschengeist/plugins/events/models.py @@ -0,0 +1,142 @@ +from __future__ import annotations # TODO: Remove if python requirement is >= 3.10 + +import enum +from datetime import datetime +from typing import Optional, Union + +from sqlalchemy import UniqueConstraint + +from flaschengeist.models import ModelSerializeMixin, UtcDateTime +from flaschengeist.models.user import User +from flaschengeist.database import db + +######### +# Types # +######### + +_table_prefix_ = "events_" + + +class EventType(db.Model, ModelSerializeMixin): + __tablename__ = _table_prefix_ + "event_type" + id: int = db.Column(db.Integer, primary_key=True) + name: str = db.Column(db.String(30), nullable=False, unique=True) + + +class JobType(db.Model, ModelSerializeMixin): + __tablename__ = _table_prefix_ + "job_type" + id: int = db.Column(db.Integer, primary_key=True) + name: str = db.Column(db.String(30), nullable=False, unique=True) + + +######## +# Jobs # +######## + + +class Service(db.Model, ModelSerializeMixin): + __tablename__ = _table_prefix_ + "service" + userid: str = "" + value: float = db.Column(db.Numeric(precision=3, scale=2, asdecimal=False), nullable=False) + + _job_id = db.Column( + "job_id", db.Integer, db.ForeignKey(f"{_table_prefix_}job.id"), nullable=False, primary_key=True + ) + _user_id = db.Column("user_id", db.Integer, db.ForeignKey("user.id"), nullable=False, primary_key=True) + + user_: User = db.relationship("User") + job_: Job = db.relationship("Job") + + @property + def userid(self): + return self.user_.userid + + +class Job(db.Model, ModelSerializeMixin): + __tablename__ = _table_prefix_ + "job" + _type_id = db.Column("type_id", db.Integer, db.ForeignKey(f"{_table_prefix_}job_type.id"), nullable=False) + + id: int = db.Column(db.Integer, primary_key=True) + start: datetime = db.Column(UtcDateTime, nullable=False) + end: Optional[datetime] = db.Column(UtcDateTime) + type: Union[JobType, int] = db.relationship("JobType") + comment: Optional[str] = db.Column(db.String(256)) + services: list[Service] = db.relationship("Service", back_populates="job_") + required_services: float = db.Column(db.Numeric(precision=4, scale=2, asdecimal=False), nullable=False) + + event_ = db.relationship("Event", back_populates="jobs") + event_id_ = db.Column("event_id", db.Integer, db.ForeignKey(f"{_table_prefix_}event.id"), nullable=False) + + __table_args__ = (UniqueConstraint("type_id", "start", name="_type_start_uc"),) + + +########## +# Events # +########## + + +class _Frequency(enum.Enum): + daily = 1 + weekly = 2 + monthly = 3 + yearly = 4 + + +class RecurrenceRule(db.Model, ModelSerializeMixin): + __tablename__ = _table_prefix_ + "recurrence_rule" + + frequency: str = db.Column(db.Enum(_Frequency)) + until: Optional[datetime] = db.Column(UtcDateTime) + count: Optional[int] = db.Column(db.Integer) + interval: int = db.Column(db.Integer, nullable=False, default=1) + + id_: int = db.Column("id", db.Integer, primary_key=True) + + +class Event(db.Model, ModelSerializeMixin): + """Model for an Event""" + + __tablename__ = _table_prefix_ + "event" + id: int = db.Column(db.Integer, primary_key=True) + start: datetime = db.Column(UtcDateTime, nullable=False) + end: Optional[datetime] = db.Column(UtcDateTime) + description: Optional[str] = db.Column(db.String(255)) + type: Union[EventType, int] = db.relationship("EventType") + jobs: list[Job] = db.relationship( + "Job", back_populates="event_", cascade="all,delete,delete-orphan", order_by="[Job.start, Job.end]" + ) + recurrence_rule: Optional[RecurrenceRule] = db.relationship("RecurrenceRule") + template_id: Optional[int] = db.Column("template_id", db.Integer, db.ForeignKey(f"{_table_prefix_}event.id")) + # Not exported properties for backend use + template_ = db.relationship("Event") + # Protected for internal use + _recurrence_rule_id = db.Column( + "recurrence_rule_id", db.Integer, db.ForeignKey(f"{_table_prefix_}recurrence_rule.id") + ) + _type_id = db.Column( + "type_id", db.Integer, db.ForeignKey(f"{_table_prefix_}event_type.id", ondelete="CASCADE"), nullable=False + ) + + +class Invite(db.Model, ModelSerializeMixin): + __tablename__ = _table_prefix_ + "invite" + + id: int = db.Column(db.Integer, primary_key=True) + job_id: int = db.Column(db.Integer, db.ForeignKey(_table_prefix_ + "job.id"), nullable=False) + # Dummy properties for API export + invitee_id: str = None + sender_id: str = None + # Not exported properties for backend use + invitee_: User = db.relationship("User", foreign_keys="Invite._invitee_id") + sender_: User = db.relationship("User", foreign_keys="Invite._sender_id") + # Protected properties needed for internal use + _invitee_id = db.Column("invitee_id", db.Integer, db.ForeignKey("user.id"), nullable=False) + _sender_id = db.Column("sender_id", db.Integer, db.ForeignKey("user.id"), nullable=False) + + @property + def invitee_id(self): + return self.invitee_.userid + + @property + def sender_id(self): + return self.sender_.userid diff --git a/flaschengeist/plugins/schedule/permissions.py b/flaschengeist/plugins/events/permissions.py similarity index 100% rename from flaschengeist/plugins/schedule/permissions.py rename to flaschengeist/plugins/events/permissions.py diff --git a/flaschengeist/plugins/schedule/models.py b/flaschengeist/plugins/schedule/models.py deleted file mode 100644 index 3b5993e..0000000 --- a/flaschengeist/plugins/schedule/models.py +++ /dev/null @@ -1,88 +0,0 @@ -from __future__ import annotations # TODO: Remove if python requirement is >= 3.10 - -from datetime import datetime -from typing import Optional, Union - -from sqlalchemy import UniqueConstraint - -from flaschengeist.models import ModelSerializeMixin, UtcDateTime -from flaschengeist.models.user import User -from flaschengeist.database import db - -######### -# Types # -######### - - -class EventType(db.Model, ModelSerializeMixin): - __tablename__ = "schedule_event_type" - id: int = db.Column(db.Integer, primary_key=True) - name: str = db.Column(db.String(30), nullable=False, unique=True) - - -class JobType(db.Model, ModelSerializeMixin): - __tablename__ = "schedule_job_type" - id: int = db.Column(db.Integer, primary_key=True) - name: str = db.Column(db.String(30), nullable=False, unique=True) - - -######## -# Jobs # -######## - - -class Service(db.Model, ModelSerializeMixin): - __tablename__ = "schedule_service" - userid: str = "" - value: float = db.Column(db.Numeric(precision=3, scale=2, asdecimal=False), nullable=False) - - _job_id = db.Column("job_id", db.Integer, db.ForeignKey("schedule_job.id"), nullable=False, primary_key=True) - _user_id = db.Column("user_id", db.Integer, db.ForeignKey("user.id"), nullable=False, primary_key=True) - - user_: User = db.relationship("User") - job_ = db.relationship("Job") - - @property - def userid(self): - return self.user_.userid - - -class Job(db.Model, ModelSerializeMixin): - __tablename__ = "schedule_job" - _type_id = db.Column("type_id", db.Integer, db.ForeignKey("schedule_job_type.id"), nullable=False) - _event_id = db.Column("event_id", db.Integer, db.ForeignKey("schedule_event.id"), nullable=False) - - id: int = db.Column(db.Integer, primary_key=True) - start: datetime = db.Column(UtcDateTime, nullable=False) - end: Optional[datetime] = db.Column(UtcDateTime) - type: Union[JobType, int] = db.relationship("JobType") - comment: Optional[str] = db.Column(db.String(256)) - services: list[Service] = db.relationship("Service", back_populates="job_") - required_services: float = db.Column(db.Numeric(precision=4, scale=2, asdecimal=False), nullable=False) - - event_ = db.relationship("Event", back_populates="jobs") - - __table_args__ = (UniqueConstraint("type_id", "start", name="_type_start_uc"),) - - -########## -# Events # -########## - - -class Event(db.Model, ModelSerializeMixin): - """Model for an Event""" - - __tablename__ = "schedule_event" - _type_id = db.Column( - "type_id", db.Integer, db.ForeignKey("schedule_event_type.id", ondelete="CASCADE"), nullable=False - ) - - id: int = db.Column(db.Integer, primary_key=True) - start: datetime = db.Column(UtcDateTime, nullable=False) - end: Optional[datetime] = db.Column(UtcDateTime) - description: Optional[str] = db.Column(db.String(255)) - type: Union[EventType, int] = db.relationship("EventType") - jobs: list[Job] = db.relationship( - "Job", back_populates="event_", cascade="all,delete,delete-orphan", order_by="[Job.start, Job.end]" - ) diff --git a/setup.py b/setup.py index 42bdd9a..191d04d 100644 --- a/setup.py +++ b/setup.py @@ -34,7 +34,7 @@ setup( "users = flaschengeist.plugins.users:UsersPlugin", "roles = flaschengeist.plugins.roles:RolesPlugin", "balance = flaschengeist.plugins.balance:BalancePlugin", - "schedule = flaschengeist.plugins.schedule:SchedulePlugin", + "events = flaschengeist.plugins.events:EventPlugin", "mail = flaschengeist.plugins.message_mail:MailMessagePlugin", "pricelist = flaschengeist.plugins.pricelist:PriceListPlugin", ], diff --git a/tests/test_events.py b/tests/test_events.py new file mode 100644 index 0000000..a847ce1 --- /dev/null +++ b/tests/test_events.py @@ -0,0 +1,17 @@ +import pytest +from werkzeug.exceptions import BadRequest + +import flaschengeist.plugins.events.event_controller as event_controller +from flaschengeist.plugins.events.models import EventType + +VALID_TOKEN = "f4ecbe14be3527ca998143a49200e294" +EVENT_TYPE_NAME = "Test Type" + + +def test_create_event_type(app): + with app.app_context(): + type = event_controller.create_event_type(EVENT_TYPE_NAME) + assert isinstance(type, EventType) + + with pytest.raises(BadRequest): + event_controller.create_event_type(EVENT_TYPE_NAME)