From c5c14f9675ecca86d77df8f62b389e49add6722d Mon Sep 17 00:00:00 2001 From: Daniel Grams Date: Tue, 9 Feb 2021 11:12:15 +0100 Subject: [PATCH] API Write Access with OAuth2 #104 --- .../{ddb85cb1c21e_.py => 1fb9f679defb_.py} | 14 +- project/api/__init__.py | 71 ++-- project/api/event/resources.py | 63 +++- project/api/event/schemas.py | 215 +++++++++--- project/api/event_category/schemas.py | 19 +- project/api/event_date/schemas.py | 3 + project/api/event_reference/schemas.py | 1 + project/api/fields.py | 11 + project/api/image/schemas.py | 1 + project/api/location/schemas.py | 23 +- project/api/organization/resources.py | 62 +++- project/api/organization/schemas.py | 1 + project/api/organizer/resources.py | 16 +- project/api/organizer/schemas.py | 28 +- project/api/place/resources.py | 16 +- project/api/place/schemas.py | 27 +- project/api/resources.py | 25 ++ project/api/schemas.py | 27 +- project/jsonld.py | 4 +- project/models.py | 21 +- project/services/event.py | 6 +- project/utils.py | 22 ++ tests/api/test___init__.py | 46 ++- tests/api/test_event.py | 314 ++++++++++++++++++ tests/api/test_fields.py | 2 +- tests/api/test_organization.py | 55 ++- tests/seeder.py | 6 + tests/utils.py | 9 + tests/views/test_event.py | 2 +- 29 files changed, 937 insertions(+), 173 deletions(-) rename migrations/versions/{ddb85cb1c21e_.py => 1fb9f679defb_.py} (88%) diff --git a/migrations/versions/ddb85cb1c21e_.py b/migrations/versions/1fb9f679defb_.py similarity index 88% rename from migrations/versions/ddb85cb1c21e_.py rename to migrations/versions/1fb9f679defb_.py index 3aa09b9..1cabf11 100644 --- a/migrations/versions/ddb85cb1c21e_.py +++ b/migrations/versions/1fb9f679defb_.py @@ -1,8 +1,8 @@ """empty message -Revision ID: ddb85cb1c21e +Revision ID: 1fb9f679defb Revises: b1a6e7630185 -Create Date: 2021-02-02 15:17:21.988363 +Create Date: 2021-02-07 17:54:44.257540 """ from alembic import op @@ -12,7 +12,7 @@ from project import dbtypes # revision identifiers, used by Alembic. -revision = "ddb85cb1c21e" +revision = "1fb9f679defb" down_revision = "b1a6e7630185" branch_labels = None depends_on = None @@ -74,6 +74,10 @@ def upgrade(): ["refresh_token"], unique=False, ) + op.alter_column( + "event", "event_place_id", existing_type=sa.INTEGER(), nullable=False + ) + op.alter_column("event", "organizer_id", existing_type=sa.INTEGER(), nullable=False) op.create_unique_constraint( "eventplace_name_admin_unit_id", "eventplace", ["name", "admin_unit_id"] ) @@ -83,6 +87,10 @@ def upgrade(): def downgrade(): # ### commands auto generated by Alembic - please adjust! ### op.drop_constraint("eventplace_name_admin_unit_id", "eventplace", type_="unique") + op.alter_column("event", "organizer_id", existing_type=sa.INTEGER(), nullable=True) + op.alter_column( + "event", "event_place_id", existing_type=sa.INTEGER(), nullable=True + ) op.drop_index(op.f("ix_oauth2_token_refresh_token"), table_name="oauth2_token") op.drop_table("oauth2_token") op.drop_table("oauth2_code") diff --git a/project/api/__init__.py b/project/api/__init__.py index 8bd96ca..4ca00d0 100644 --- a/project/api/__init__.py +++ b/project/api/__init__.py @@ -1,6 +1,6 @@ from flask_restful import Api from sqlalchemy.exc import IntegrityError -from psycopg2.errorcodes import UNIQUE_VIOLATION +from psycopg2.errorcodes import UNIQUE_VIOLATION, CHECK_VIOLATION from werkzeug.exceptions import HTTPException, UnprocessableEntity from marshmallow import ValidationError from project.utils import get_localized_scope @@ -22,15 +22,23 @@ class RestApi(Api): data = {} code = 500 - if ( - isinstance(err, IntegrityError) - and err.orig - and err.orig.pgcode == UNIQUE_VIOLATION - ): - data["name"] = "Unique Violation" - data[ - "message" - ] = "An entry with the entered values ​​already exists. Duplicate entries are not allowed." + if isinstance(err, IntegrityError) and err.orig: + if err.orig.pgcode == UNIQUE_VIOLATION: + data["name"] = "Unique Violation" + data[ + "message" + ] = "An entry with the entered values ​​already exists. Duplicate entries are not allowed." + elif err.orig.pgcode == CHECK_VIOLATION: + data["name"] = "Check Violation" + + if hasattr(err.orig, "message") and getattr(err.orig, "message", None): + data["message"] = err.orig.message + else: + data["message"] = "Action violates database constraint." + + else: + data["name"] = "Integrity Error" + data["message"] = "Action violates database integrity." code = 400 schema = ErrorResponseSchema() elif isinstance(err, HTTPException): @@ -47,25 +55,18 @@ class RestApi(Api): data["message"] = err.description code = err.code schema = UnprocessableEntityResponseSchema() + self.fill_validation_data(err.exc, data) - if ( - getattr(err.exc, "args", None) - and isinstance(err.exc.args, tuple) - and len(err.exc.args) > 0 - ): - arg = err.exc.args[0] - if isinstance(arg, dict): - errors = [] - for field, messages in arg.items(): - if isinstance(messages, list): - for message in messages: - error = {"field": field, "message": message} - errors.append(error) - - if len(errors) > 0: - data["errors"] = errors else: schema = ErrorResponseSchema() + elif isinstance(err, ValidationError): + data["name"] = "Unprocessable Entity" + data[ + "message" + ] = "The request was well-formed but was unable to be followed due to semantic errors." + code = 422 + schema = UnprocessableEntityResponseSchema() + self.fill_validation_data(err, data) # Call default error handler that propagates error further try: @@ -76,6 +77,24 @@ class RestApi(Api): return schema.dump(data), code + def fill_validation_data(self, err: ValidationError, data: dict): + if ( + getattr(err, "args", None) + and isinstance(err.args, tuple) + and len(err.args) > 0 + ): + arg = err.args[0] + if isinstance(arg, dict): + errors = [] + for field, messages in arg.items(): + if isinstance(messages, list): + for message in messages: + error = {"field": field, "message": message} + errors.append(error) + + if len(errors) > 0: + data["errors"] = errors + scope_list = [ "organizer:write", diff --git a/project/api/event/resources.py b/project/api/event/resources.py index 99bbec1..ec74dc7 100644 --- a/project/api/event/resources.py +++ b/project/api/event/resources.py @@ -1,4 +1,5 @@ from project.api import add_api_resource +from flask import make_response from flask_apispec import marshal_with, doc, use_kwargs from project.api.resources import BaseResource from project.api.event.schemas import ( @@ -7,15 +8,26 @@ from project.api.event.schemas import ( EventListResponseSchema, EventSearchRequestSchema, EventSearchResponseSchema, + EventPostRequestSchema, + EventPatchRequestSchema, ) from project.api.event_date.schemas import ( EventDateListRequestSchema, EventDateListResponseSchema, ) from project.models import Event, EventDate -from project.services.event import get_events_query, get_event_with_details_or_404 +from project.services.event import ( + get_events_query, + get_event_with_details_or_404, + update_event, +) from project.services.event_search import EventSearchParams from sqlalchemy.orm import lazyload, load_only +from project.oauth2 import require_oauth +from authlib.integrations.flask_oauth2 import current_token +from project import db +from project.access import access_or_401, login_api_user_or_401 +from project.views.event import send_referenced_event_changed_mails class EventListResource(BaseResource): @@ -33,6 +45,55 @@ class EventResource(BaseResource): def get(self, id): return get_event_with_details_or_404(id) + @doc( + summary="Update event", tags=["Events"], security=[{"oauth2": ["event:write"]}] + ) + @use_kwargs(EventPostRequestSchema, location="json", apply=False) + @marshal_with(None, 204) + @require_oauth("event:write") + def put(self, id): + login_api_user_or_401(current_token.user) + event = Event.query.get_or_404(id) + access_or_401(event.admin_unit_id, "event:update") + + event = self.update_instance(EventPostRequestSchema, instance=event) + update_event(event) + db.session.commit() + send_referenced_event_changed_mails(event) + + return make_response("", 204) + + @doc(summary="Patch event", tags=["Events"], security=[{"oauth2": ["event:write"]}]) + @use_kwargs(EventPatchRequestSchema, location="json", apply=False) + @marshal_with(None, 204) + @require_oauth("event:write") + def patch(self, id): + login_api_user_or_401(current_token.user) + event = Event.query.get_or_404(id) + access_or_401(event.admin_unit_id, "event:update") + + event = self.update_instance(EventPatchRequestSchema, instance=event) + update_event(event) + db.session.commit() + send_referenced_event_changed_mails(event) + + return make_response("", 204) + + @doc( + summary="Delete event", tags=["Events"], security=[{"oauth2": ["event:write"]}] + ) + @marshal_with(None, 204) + @require_oauth("event:write") + def delete(self, id): + login_api_user_or_401(current_token.user) + event = Event.query.get_or_404(id) + access_or_401(event.admin_unit_id, "event:delete") + + db.session.delete(event) + db.session.commit() + + return make_response("", 204) + class EventDatesResource(BaseResource): @doc(summary="List dates for event", tags=["Events", "Event Dates"]) diff --git a/project/api/event/schemas.py b/project/api/event/schemas.py index 7b904f1..70a6e97 100644 --- a/project/api/event/schemas.py +++ b/project/api/event/schemas.py @@ -1,5 +1,5 @@ from project.api import marshmallow -from marshmallow import fields, validate +from marshmallow import fields, validate, ValidationError from marshmallow_enum import EnumField from project.models import ( Event, @@ -7,51 +7,145 @@ from project.models import ( EventTargetGroupOrigin, EventAttendanceMode, ) -from project.api.schemas import PaginationRequestSchema, PaginationResponseSchema +from project.api.schemas import ( + SQLAlchemyBaseSchema, + IdSchemaMixin, + TrackableSchemaMixin, + PaginationRequestSchema, + PaginationResponseSchema, +) from project.api.organization.schemas import OrganizationRefSchema -from project.api.organizer.schemas import OrganizerRefSchema +from project.api.organizer.schemas import OrganizerRefSchema, OrganizerWriteIdSchema from project.api.image.schemas import ImageSchema -from project.api.place.schemas import PlaceRefSchema, PlaceSearchItemSchema +from project.api.place.schemas import ( + PlaceRefSchema, + PlaceSearchItemSchema, + PlaceWriteIdSchema, +) from project.api.event_category.schemas import ( EventCategoryRefSchema, EventCategoryIdSchema, + EventCategoryWriteIdSchema, ) +from project.api.fields import CustomDateTimeField +from dateutil.rrule import rrulestr -class EventBaseSchema(marshmallow.SQLAlchemySchema): +class EventModelSchema(SQLAlchemyBaseSchema): class Meta: model = Event - - id = marshmallow.auto_field() - created_at = marshmallow.auto_field() - updated_at = marshmallow.auto_field() - - name = marshmallow.auto_field() - description = marshmallow.auto_field() - external_link = marshmallow.auto_field() - ticket_link = marshmallow.auto_field() - - tags = marshmallow.auto_field() - kid_friendly = marshmallow.auto_field() - accessible_for_free = marshmallow.auto_field() - age_from = marshmallow.auto_field() - age_to = marshmallow.auto_field() - target_group_origin = EnumField(EventTargetGroupOrigin) - attendance_mode = EnumField(EventAttendanceMode) - status = EnumField(EventStatus) - previous_start_date = marshmallow.auto_field() - - registration_required = marshmallow.auto_field() - booked_up = marshmallow.auto_field() - expected_participants = marshmallow.auto_field() - price_info = marshmallow.auto_field() - - recurrence_rule = marshmallow.auto_field() - start = marshmallow.auto_field() - end = marshmallow.auto_field() + load_instance = True -class EventSchema(EventBaseSchema): +class EventIdSchema(EventModelSchema, IdSchemaMixin): + pass + + +def validate_recurrence_rule(recurrence_rule): + try: + rrulestr(recurrence_rule, forceset=True) + except Exception as e: + raise ValidationError(str(e)) + + +class EventBaseSchemaMixin(TrackableSchemaMixin): + name = marshmallow.auto_field( + required=True, + validate=validate.Length(min=3, max=255), + metadata={"description": "A short, meaningful name for the event."}, + ) + description = marshmallow.auto_field( + metadata={"description": "Description of the event"}, + ) + external_link = marshmallow.auto_field( + validate=[validate.URL(), validate.Length(max=255)], + metadata={ + "description": "A link to an external website containing more information about the event." + }, + ) + ticket_link = marshmallow.auto_field( + validate=[validate.URL(), validate.Length(max=255)], + metadata={"description": "A link where tickets can be purchased."}, + ) + tags = marshmallow.auto_field( + metadata={ + "description": "Comma separated keywords with which the event should be found. Words do not need to be entered if they are already in the name or description." + } + ) + kid_friendly = marshmallow.auto_field( + missing=False, + metadata={"description": "If the event is particularly suitable for children."}, + ) + accessible_for_free = marshmallow.auto_field( + missing=False, + metadata={"description": "If the event is accessible for free."}, + ) + age_from = marshmallow.auto_field( + metadata={"description": "The minimum age that participants should be."}, + ) + age_to = marshmallow.auto_field( + metadata={"description": "The maximum age that participants should be."}, + ) + target_group_origin = EnumField( + EventTargetGroupOrigin, + missing=EventTargetGroupOrigin.both, + metadata={ + "description": "Whether the event is particularly suitable for tourists or residents." + }, + ) + attendance_mode = EnumField( + EventAttendanceMode, + missing=EventAttendanceMode.offline, + metadata={"description": "Choose how people can attend the event."}, + ) + status = EnumField( + EventStatus, + missing=EventStatus.scheduled, + metadata={"description": "Select the status of the event."}, + ) + previous_start_date = CustomDateTimeField( + metadata={ + "description": "When the event should have taken place before it was postponed." + }, + ) + registration_required = marshmallow.auto_field( + missing=False, + metadata={ + "description": "If the participants needs to register for the event." + }, + ) + booked_up = marshmallow.auto_field( + missing=False, + metadata={"description": "If the event is booked up or sold out."}, + ) + expected_participants = marshmallow.auto_field( + metadata={"description": "The estimated expected attendance."}, + ) + price_info = marshmallow.auto_field( + metadata={ + "description": "Price information in textual form. E.g., different prices for adults and children." + }, + ) + recurrence_rule = marshmallow.auto_field( + validate=validate_recurrence_rule, + metadata={ + "description": "If the event takes place regularly. Format: RFC 5545." + }, + ) + start = CustomDateTimeField( + required=True, + metadata={ + "description": "When the event will take place. If the event takes place regularly, enter when the first date will begin." + }, + ) + end = CustomDateTimeField( + metadata={ + "description": "When the event will end. An event can last a maximum of 24 hours. If the event takes place regularly, enter when the first date will end." + }, + ) + + +class EventSchema(EventIdSchema, EventBaseSchemaMixin): organization = fields.Nested(OrganizationRefSchema, attribute="admin_unit") organizer = fields.Nested(OrganizerRefSchema) place = fields.Nested(PlaceRefSchema, attribute="event_place") @@ -59,7 +153,7 @@ class EventSchema(EventBaseSchema): categories = fields.List(fields.Nested(EventCategoryRefSchema)) -class EventDumpSchema(EventBaseSchema): +class EventDumpSchema(EventIdSchema, EventBaseSchemaMixin): organization_id = fields.Int(attribute="admin_unit_id") organizer_id = fields.Int() place_id = fields.Int(attribute="event_place_id") @@ -69,18 +163,11 @@ class EventDumpSchema(EventBaseSchema): ) -class EventRefSchema(marshmallow.SQLAlchemySchema): - class Meta: - model = Event - - id = marshmallow.auto_field() +class EventRefSchema(EventIdSchema): name = marshmallow.auto_field() class EventSearchItemSchema(EventRefSchema): - class Meta: - model = Event - description = marshmallow.auto_field() start = marshmallow.auto_field() end = marshmallow.auto_field() @@ -145,3 +232,45 @@ class EventSearchResponseSchema(PaginationResponseSchema): items = fields.List( fields.Nested(EventSearchItemSchema), metadata={"description": "Events"} ) + + +class EventWriteSchemaMixin(object): + organizer = fields.Nested( + OrganizerWriteIdSchema, + required=True, + metadata={"description": "Who is organizing the event."}, + ) + place = fields.Nested( + PlaceWriteIdSchema, + required=True, + attribute="event_place", + metadata={"description": "Where the event takes place."}, + ) + categories = fields.List( + fields.Nested(EventCategoryWriteIdSchema), + metadata={"description": "Categories that fit the event."}, + ) + rating = marshmallow.auto_field( + missing=50, + default=50, + validate=validate.OneOf([0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100]), + metadata={ + "description": "How relevant the event is to your organization. 0 (Little relevant), 5 (Default), 10 (Highlight)." + }, + ) + + +class EventPostRequestSchema( + EventModelSchema, EventBaseSchemaMixin, EventWriteSchemaMixin +): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.make_post_schema() + + +class EventPatchRequestSchema( + EventModelSchema, EventBaseSchemaMixin, EventWriteSchemaMixin +): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.make_patch_schema() diff --git a/project/api/event_category/schemas.py b/project/api/event_category/schemas.py index 0bd66bd..faebdc5 100644 --- a/project/api/event_category/schemas.py +++ b/project/api/event_category/schemas.py @@ -1,20 +1,33 @@ from marshmallow import fields from project.api import marshmallow from project.models import EventCategory -from project.api.schemas import PaginationRequestSchema, PaginationResponseSchema +from project.api.schemas import ( + SQLAlchemyBaseSchema, + IdSchemaMixin, + PaginationRequestSchema, + PaginationResponseSchema, + WriteIdSchemaMixin, +) -class EventCategoryIdSchema(marshmallow.SQLAlchemySchema): +class EventCategoryModelSchema(SQLAlchemyBaseSchema): class Meta: model = EventCategory + load_instance = True - id = marshmallow.auto_field() + +class EventCategoryIdSchema(EventCategoryModelSchema, IdSchemaMixin): + pass class EventCategoryRefSchema(EventCategoryIdSchema): name = marshmallow.auto_field() +class EventCategoryWriteIdSchema(EventCategoryModelSchema, WriteIdSchemaMixin): + pass + + class EventCategoryDumpSchema(EventCategoryRefSchema): pass diff --git a/project/api/event_date/schemas.py b/project/api/event_date/schemas.py index 3efa0f5..8d283ab 100644 --- a/project/api/event_date/schemas.py +++ b/project/api/event_date/schemas.py @@ -12,6 +12,7 @@ from project.api.schemas import PaginationRequestSchema, PaginationResponseSchem class EventDateSchema(marshmallow.SQLAlchemySchema): class Meta: model = EventDate + load_instance = True id = marshmallow.auto_field() start = marshmallow.auto_field() @@ -22,6 +23,7 @@ class EventDateSchema(marshmallow.SQLAlchemySchema): class EventDateRefSchema(marshmallow.SQLAlchemySchema): class Meta: model = EventDate + load_instance = True id = marshmallow.auto_field() start = marshmallow.auto_field() @@ -44,6 +46,7 @@ class EventDateSearchRequestSchema(EventSearchRequestSchema): class EventDateSearchItemSchema(EventDateRefSchema): class Meta: model = EventDate + load_instance = True end = marshmallow.auto_field() event = fields.Nested(EventSearchItemSchema) diff --git a/project/api/event_reference/schemas.py b/project/api/event_reference/schemas.py index 02808c7..df3200c 100644 --- a/project/api/event_reference/schemas.py +++ b/project/api/event_reference/schemas.py @@ -9,6 +9,7 @@ from project.api.organization.schemas import OrganizationRefSchema class EventReferenceIdSchema(marshmallow.SQLAlchemySchema): class Meta: model = EventReference + load_instance = True id = marshmallow.auto_field() diff --git a/project/api/fields.py b/project/api/fields.py index 90c9bd5..2d54149 100644 --- a/project/api/fields.py +++ b/project/api/fields.py @@ -1,4 +1,5 @@ from marshmallow import fields, ValidationError +from project.dateutils import berlin_tz class NumericStr(fields.String): @@ -13,3 +14,13 @@ class NumericStr(fields.String): return float(value) except ValueError as error: raise ValidationError("Must be a numeric value.") from error + + +class CustomDateTimeField(fields.DateTime): + def deserialize(self, value, attr, data, **kwargs): + result = super().deserialize(value, attr, data, **kwargs) + + if result and result.tzinfo is None: + result = berlin_tz.localize(result) + + return result diff --git a/project/api/image/schemas.py b/project/api/image/schemas.py index 02f9a49..7732b0c 100644 --- a/project/api/image/schemas.py +++ b/project/api/image/schemas.py @@ -5,6 +5,7 @@ from project.models import Image class ImageIdSchema(marshmallow.SQLAlchemySchema): class Meta: model = Image + load_instance = True id = marshmallow.auto_field() diff --git a/project/api/location/schemas.py b/project/api/location/schemas.py index 131bcc3..f4155f4 100644 --- a/project/api/location/schemas.py +++ b/project/api/location/schemas.py @@ -2,16 +2,13 @@ from marshmallow import validate, validates_schema, ValidationError from project.api import marshmallow from project.models import Location from project.api.fields import NumericStr -from project.api.schemas import ( - SQLAlchemyBaseSchema, - PostSchema, - PatchSchema, -) +from project.api.schemas import SQLAlchemyBaseSchema class LocationModelSchema(SQLAlchemyBaseSchema): class Meta: model = Location + load_instance = True class LocationBaseSchemaMixin(object): @@ -55,13 +52,13 @@ class LocationSearchItemSchema(LocationSchema): pass -class LocationPostRequestSchema( - PostSchema, LocationModelSchema, LocationBaseSchemaMixin -): - pass +class LocationPostRequestSchema(LocationModelSchema, LocationBaseSchemaMixin): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.make_post_schema() -class LocationPatchRequestSchema( - PatchSchema, LocationModelSchema, LocationBaseSchemaMixin -): - pass +class LocationPatchRequestSchema(LocationModelSchema, LocationBaseSchemaMixin): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.make_patch_schema() diff --git a/project/api/organization/resources.py b/project/api/organization/resources.py index 9484ca7..c8ce1bd 100644 --- a/project/api/organization/resources.py +++ b/project/api/organization/resources.py @@ -6,7 +6,7 @@ from project.api.organization.schemas import ( OrganizationListRequestSchema, OrganizationListResponseSchema, ) -from project.models import AdminUnit +from project.models import AdminUnit, Event from project.api.event_date.schemas import ( EventDateSearchRequestSchema, EventDateSearchResponseSchema, @@ -14,6 +14,10 @@ from project.api.event_date.schemas import ( from project.api.event.schemas import ( EventSearchRequestSchema, EventSearchResponseSchema, + EventListRequestSchema, + EventListResponseSchema, + EventPostRequestSchema, + EventIdSchema, ) from project.api.organizer.schemas import ( OrganizerListRequestSchema, @@ -35,7 +39,7 @@ from project.api.place.schemas import ( PlaceIdSchema, PlacePostRequestSchema, ) -from project.services.event import get_event_dates_query, get_events_query +from project.services.event import get_event_dates_query, get_events_query, insert_event from project.services.event_search import EventSearchParams from project.services.admin_unit import ( get_admin_unit_query, @@ -93,6 +97,37 @@ class OrganizationEventSearchResource(BaseResource): return pagination +class OrganizationEventListResource(BaseResource): + @doc(summary="List events of organization", tags=["Organizations", "Events"]) + @use_kwargs(EventListRequestSchema, location=("query")) + @marshal_with(EventListResponseSchema) + def get(self, id, **kwargs): + admin_unit = AdminUnit.query.get_or_404(id) + pagination = Event.query.filter(Event.admin_unit_id == admin_unit.id).paginate() + return pagination + + @doc( + summary="Add new event", + tags=["Organizations", "Events"], + security=[{"oauth2": ["event:write"]}], + ) + @use_kwargs(EventPostRequestSchema, location="json", apply=False) + @marshal_with(EventIdSchema, 201) + @require_oauth("event:write") + def post(self, id): + login_api_user_or_401(current_token.user) + admin_unit = get_admin_unit_for_manage_or_404(id) + access_or_401(admin_unit, "event:create") + + event = self.create_instance( + EventPostRequestSchema, admin_unit_id=admin_unit.id + ) + insert_event(event) + db.session.commit() + + return event, 201 + + class OrganizationListResource(BaseResource): @doc(summary="List organizations", tags=["Organizations"]) @use_kwargs(OrganizationListRequestSchema, location=("query")) @@ -121,18 +156,17 @@ class OrganizationOrganizerListResource(BaseResource): tags=["Organizations", "Organizers"], security=[{"oauth2": ["organizer:write"]}], ) - @use_kwargs(OrganizerPostRequestSchema, location="json") + @use_kwargs(OrganizerPostRequestSchema, location="json", apply=False) @marshal_with(OrganizerIdSchema, 201) @require_oauth("organizer:write") - def post(self, id, **kwargs): + def post(self, id): login_api_user_or_401(current_token.user) admin_unit = get_admin_unit_for_manage_or_404(id) access_or_401(admin_unit, "organizer:create") - organizer = OrganizerPostRequestSchema(load_instance=True).load( - kwargs, session=db.session + organizer = self.create_instance( + OrganizerPostRequestSchema, admin_unit_id=admin_unit.id ) - organizer.admin_unit_id = admin_unit.id db.session.add(organizer) db.session.commit() @@ -155,18 +189,17 @@ class OrganizationPlaceListResource(BaseResource): tags=["Organizations", "Places"], security=[{"oauth2": ["place:write"]}], ) - @use_kwargs(PlacePostRequestSchema, location="json") + @use_kwargs(PlacePostRequestSchema, location="json", apply=False) @marshal_with(PlaceIdSchema, 201) @require_oauth("place:write") - def post(self, id, **kwargs): + def post(self, id): login_api_user_or_401(current_token.user) admin_unit = get_admin_unit_for_manage_or_404(id) access_or_401(admin_unit, "place:create") - place = PlacePostRequestSchema(load_instance=True).load( - kwargs, session=db.session + place = self.create_instance( + PlacePostRequestSchema, admin_unit_id=admin_unit.id ) - place.admin_unit_id = admin_unit.id db.session.add(place) db.session.commit() @@ -212,6 +245,11 @@ add_api_resource( "/organizations//events/search", "api_v1_organization_event_search", ) +add_api_resource( + OrganizationEventListResource, + "/organizations//events", + "api_v1_organization_event_list", +) add_api_resource(OrganizationListResource, "/organizations", "api_v1_organization_list") add_api_resource( OrganizationOrganizerListResource, diff --git a/project/api/organization/schemas.py b/project/api/organization/schemas.py index 2587432..0878df9 100644 --- a/project/api/organization/schemas.py +++ b/project/api/organization/schemas.py @@ -9,6 +9,7 @@ from project.api.schemas import PaginationRequestSchema, PaginationResponseSchem class OrganizationIdSchema(marshmallow.SQLAlchemySchema): class Meta: model = AdminUnit + load_instance = True id = marshmallow.auto_field() diff --git a/project/api/organizer/resources.py b/project/api/organizer/resources.py index 61edd5f..152d0da 100644 --- a/project/api/organizer/resources.py +++ b/project/api/organizer/resources.py @@ -25,17 +25,15 @@ class OrganizerResource(BaseResource): tags=["Organizers"], security=[{"oauth2": ["organizer:write"]}], ) - @use_kwargs(OrganizerPostRequestSchema, location="json") + @use_kwargs(OrganizerPostRequestSchema, location="json", apply=False) @marshal_with(None, 204) @require_oauth("organizer:write") - def put(self, id, **kwargs): + def put(self, id): login_api_user_or_401(current_token.user) organizer = EventOrganizer.query.get_or_404(id) access_or_401(organizer.adminunit, "organizer:update") - organizer = OrganizerPostRequestSchema(load_instance=True).load( - kwargs, session=db.session, instance=organizer - ) + organizer = self.update_instance(OrganizerPostRequestSchema, instance=organizer) db.session.commit() return make_response("", 204) @@ -45,16 +43,16 @@ class OrganizerResource(BaseResource): tags=["Organizers"], security=[{"oauth2": ["organizer:write"]}], ) - @use_kwargs(OrganizerPatchRequestSchema, location="json") + @use_kwargs(OrganizerPatchRequestSchema, location="json", apply=False) @marshal_with(None, 204) @require_oauth("organizer:write") - def patch(self, id, **kwargs): + def patch(self, id): login_api_user_or_401(current_token.user) organizer = EventOrganizer.query.get_or_404(id) access_or_401(organizer.adminunit, "organizer:update") - organizer = OrganizerPatchRequestSchema(load_instance=True).load( - kwargs, session=db.session, instance=organizer + organizer = self.update_instance( + OrganizerPatchRequestSchema, instance=organizer ) db.session.commit() diff --git a/project/api/organizer/schemas.py b/project/api/organizer/schemas.py index bec2112..f45984d 100644 --- a/project/api/organizer/schemas.py +++ b/project/api/organizer/schemas.py @@ -11,9 +11,8 @@ from project.api.organization.schemas import OrganizationRefSchema from project.api.schemas import ( SQLAlchemyBaseSchema, IdSchemaMixin, + WriteIdSchemaMixin, TrackableSchemaMixin, - PostSchema, - PatchSchema, PaginationRequestSchema, PaginationResponseSchema, ) @@ -22,12 +21,17 @@ from project.api.schemas import ( class OrganizerModelSchema(SQLAlchemyBaseSchema): class Meta: model = EventOrganizer + load_instance = True class OrganizerIdSchema(OrganizerModelSchema, IdSchemaMixin): pass +class OrganizerWriteIdSchema(OrganizerModelSchema, WriteIdSchemaMixin): + pass + + class OrganizerBaseSchemaMixin(TrackableSchemaMixin): name = marshmallow.auto_field( required=True, validate=validate.Length(min=3, max=255) @@ -68,13 +72,17 @@ class OrganizerListResponseSchema(PaginationResponseSchema): ) -class OrganizerPostRequestSchema( - PostSchema, OrganizerModelSchema, OrganizerBaseSchemaMixin -): - location = fields.Nested(LocationPostRequestSchema, missing=None) +class OrganizerPostRequestSchema(OrganizerModelSchema, OrganizerBaseSchemaMixin): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.make_post_schema() + + location = fields.Nested(LocationPostRequestSchema) -class OrganizerPatchRequestSchema( - PatchSchema, OrganizerModelSchema, OrganizerBaseSchemaMixin -): - location = fields.Nested(LocationPatchRequestSchema, allow_none=True) +class OrganizerPatchRequestSchema(OrganizerModelSchema, OrganizerBaseSchemaMixin): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.make_patch_schema() + + location = fields.Nested(LocationPatchRequestSchema) diff --git a/project/api/place/resources.py b/project/api/place/resources.py index fd6f496..0a46bcf 100644 --- a/project/api/place/resources.py +++ b/project/api/place/resources.py @@ -23,33 +23,29 @@ class PlaceResource(BaseResource): @doc( summary="Update place", tags=["Places"], security=[{"oauth2": ["place:write"]}] ) - @use_kwargs(PlacePostRequestSchema, location="json") + @use_kwargs(PlacePostRequestSchema, location="json", apply=False) @marshal_with(None, 204) @require_oauth("place:write") - def put(self, id, **kwargs): + def put(self, id): login_api_user_or_401(current_token.user) place = EventPlace.query.get_or_404(id) access_or_401(place.adminunit, "place:update") - place = PlacePostRequestSchema(load_instance=True).load( - kwargs, session=db.session, instance=place - ) + place = self.update_instance(PlacePostRequestSchema, instance=place) db.session.commit() return make_response("", 204) @doc(summary="Patch place", tags=["Places"], security=[{"oauth2": ["place:write"]}]) - @use_kwargs(PlacePatchRequestSchema, location="json") + @use_kwargs(PlacePatchRequestSchema, location="json", apply=False) @marshal_with(None, 204) @require_oauth("place:write") - def patch(self, id, **kwargs): + def patch(self, id): login_api_user_or_401(current_token.user) place = EventPlace.query.get_or_404(id) access_or_401(place.adminunit, "place:update") - place = PlacePatchRequestSchema(load_instance=True).load( - kwargs, session=db.session, instance=place - ) + place = self.update_instance(PlacePatchRequestSchema, instance=place) db.session.commit() return make_response("", 204) diff --git a/project/api/place/schemas.py b/project/api/place/schemas.py index eba519e..0ab9418 100644 --- a/project/api/place/schemas.py +++ b/project/api/place/schemas.py @@ -12,9 +12,8 @@ from project.api.organization.schemas import OrganizationRefSchema from project.api.schemas import ( SQLAlchemyBaseSchema, IdSchemaMixin, + WriteIdSchemaMixin, TrackableSchemaMixin, - PostSchema, - PatchSchema, PaginationRequestSchema, PaginationResponseSchema, ) @@ -23,12 +22,17 @@ from project.api.schemas import ( class PlaceModelSchema(SQLAlchemyBaseSchema): class Meta: model = EventPlace + load_instance = True class PlaceIdSchema(PlaceModelSchema, IdSchemaMixin): pass +class PlaceWriteIdSchema(PlaceModelSchema, WriteIdSchemaMixin): + pass + + class PlaceBaseSchemaMixin(TrackableSchemaMixin): name = marshmallow.auto_field( required=True, validate=validate.Length(min=3, max=255) @@ -54,9 +58,6 @@ class PlaceRefSchema(PlaceIdSchema): class PlaceSearchItemSchema(PlaceRefSchema): - class Meta: - model = EventPlace - location = fields.Nested(LocationSearchItemSchema) @@ -72,9 +73,17 @@ class PlaceListResponseSchema(PaginationResponseSchema): ) -class PlacePostRequestSchema(PostSchema, PlaceModelSchema, PlaceBaseSchemaMixin): - location = fields.Nested(LocationPostRequestSchema, missing=None) +class PlacePostRequestSchema(PlaceModelSchema, PlaceBaseSchemaMixin): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.make_post_schema() + + location = fields.Nested(LocationPostRequestSchema) -class PlacePatchRequestSchema(PatchSchema, PlaceModelSchema, PlaceBaseSchemaMixin): - location = fields.Nested(LocationPatchRequestSchema, allow_none=True) +class PlacePatchRequestSchema(PlaceModelSchema, PlaceBaseSchemaMixin): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.make_patch_schema() + + location = fields.Nested(LocationPatchRequestSchema) diff --git a/project/api/resources.py b/project/api/resources.py index add8bef..9bb584a 100644 --- a/project/api/resources.py +++ b/project/api/resources.py @@ -2,6 +2,7 @@ from flask import request from flask_apispec import marshal_with from flask_apispec.views import MethodResource from functools import wraps +from project import db from project.api.schemas import ErrorResponseSchema, UnprocessableEntityResponseSchema @@ -19,3 +20,27 @@ def etag_cache(func): @marshal_with(UnprocessableEntityResponseSchema, 422, "Unprocessable Entity") class BaseResource(MethodResource): decorators = [etag_cache] + + def create_instance(self, schema_cls, **kwargs): + instance = schema_cls().load(request.json, session=db.session) + + for key, value in kwargs.items(): + if hasattr(instance, key): + setattr(instance, key, value) + + validate = getattr(instance, "validate", None) + if callable(validate): + validate() + + return instance + + def update_instance(self, schema_cls, instance): + instance = schema_cls().load( + request.json, session=db.session, instance=instance + ) + + validate = getattr(instance, "validate", None) + if callable(validate): + validate() + + return instance diff --git a/project/api/schemas.py b/project/api/schemas.py index 5ff3f7d..f3c56de 100644 --- a/project/api/schemas.py +++ b/project/api/schemas.py @@ -4,23 +4,20 @@ from marshmallow import fields, validate, missing class SQLAlchemyBaseSchema(marshmallow.SQLAlchemySchema): def __init__(self, *args, **kwargs): - load_instance = kwargs.pop("load_instance", False) super().__init__(*args, **kwargs) - self.opts.load_instance = load_instance - -class PostSchema(object): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - for name, field in self._declared_fields.items(): + def make_post_schema(self): + for name, field in self.fields.items(): if not field.required: - field.missing = None + if field.missing is missing: + if isinstance(field, fields.List): + field.missing = list() + else: + field.missing = None + field.allow_none = True - -class PatchSchema(object): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - for name, field in self._declared_fields.items(): + def make_patch_schema(self): + for name, field in self.fields.items(): field.required = False field.allow_none = True @@ -29,6 +26,10 @@ class IdSchemaMixin(object): id = marshmallow.auto_field(dump_only=True, default=missing) +class WriteIdSchemaMixin(object): + id = marshmallow.auto_field(required=True) + + class TrackableSchemaMixin(object): created_at = marshmallow.auto_field(dump_only=True) updated_at = marshmallow.auto_field(dump_only=True) diff --git a/project/jsonld.py b/project/jsonld.py index 99bc077..6950e3c 100644 --- a/project/jsonld.py +++ b/project/jsonld.py @@ -2,9 +2,7 @@ import datetime from json import JSONEncoder from flask import url_for from project.models import EventAttendanceMode, EventStatus -import pytz - -berlin_tz = pytz.timezone("Europe/Berlin") +from project.dateutils import berlin_tz class DateTimeEncoder(JSONEncoder): diff --git a/project/models.py b/project/models.py index 19e9280..2b136a8 100644 --- a/project/models.py +++ b/project/models.py @@ -1,4 +1,5 @@ from project import db +from project.utils import make_check_violation from sqlalchemy.ext.declarative import declared_attr from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.orm import relationship, backref, deferred, object_session @@ -30,6 +31,7 @@ from authlib.integrations.sqla_oauth2 import ( OAuth2TokenMixin, ) import time +from dateutil.relativedelta import relativedelta # Base @@ -585,11 +587,11 @@ class Event(db.Model, TrackableMixin, EventMixin): admin_unit_id = db.Column(db.Integer, db.ForeignKey("adminunit.id"), nullable=False) admin_unit = db.relationship("AdminUnit", backref=db.backref("events", lazy=True)) organizer_id = db.Column( - db.Integer, db.ForeignKey("eventorganizer.id"), nullable=True + db.Integer, db.ForeignKey("eventorganizer.id"), nullable=False ) organizer = db.relationship("EventOrganizer", uselist=False) event_place_id = db.Column( - db.Integer, db.ForeignKey("eventplace.id"), nullable=True + db.Integer, db.ForeignKey("eventplace.id"), nullable=False ) event_place = db.relationship("EventPlace", uselist=False) @@ -621,6 +623,21 @@ class Event(db.Model, TrackableMixin, EventMixin): else: return None + def validate(self): + if self.organizer and self.organizer.admin_unit_id != self.admin_unit_id: + raise make_check_violation("Invalid organizer") + + if self.event_place and self.event_place.admin_unit_id != self.admin_unit_id: + raise make_check_violation("Invalid place") + + if self.start and self.end: + if self.start > self.end: + raise make_check_violation("The start must be before the end.") + + max_end = self.start + relativedelta(days=1) + if self.end > max_end: + raise make_check_violation("An event can last a maximum of 24 hours.") + @listens_for(Event, "before_insert") @listens_for(Event, "before_update") diff --git a/project/services/event.py b/project/services/event.py index 625ddb5..05ebb2e 100644 --- a/project/services/event.py +++ b/project/services/event.py @@ -21,8 +21,12 @@ from sqlalchemy.orm import joinedload, contains_eager, defaultload from dateutil.relativedelta import relativedelta +def get_event_category(category_name): + return EventCategory.query.filter_by(name=category_name).first() + + def upsert_event_category(category_name): - result = EventCategory.query.filter_by(name=category_name).first() + result = get_event_category(category_name) if result is None: result = EventCategory(name=category_name) db.session.add(result) diff --git a/project/utils.py b/project/utils.py index 80628b6..a28c5c7 100644 --- a/project/utils.py +++ b/project/utils.py @@ -1,6 +1,8 @@ from flask_babelex import lazy_gettext import pathlib import os +from sqlalchemy.exc import IntegrityError +from psycopg2.errorcodes import UNIQUE_VIOLATION, CHECK_VIOLATION def get_event_category_name(category): @@ -28,3 +30,23 @@ def make_dir(path): def split_by_crlf(s): return [v for v in s.splitlines() if v] + + +def make_integrity_error( + pgcode: str, message: str = "", statement: str = None +) -> IntegrityError: + class Psycog2Error(object): + def __init__(self, pgcode, message): + self.pgcode = pgcode + self.message = message + + orig = Psycog2Error(pgcode, message) + return IntegrityError(statement, list(), orig) + + +def make_check_violation(message: str = None, statement: str = "") -> IntegrityError: + return make_integrity_error(CHECK_VIOLATION, message, statement) + + +def make_unique_violation(message: str = None, statement: str = "") -> IntegrityError: + return make_integrity_error(UNIQUE_VIOLATION, message, statement) diff --git a/tests/api/test___init__.py b/tests/api/test___init__.py index 55bd8da..07f3d71 100644 --- a/tests/api/test___init__.py +++ b/tests/api/test___init__.py @@ -2,17 +2,10 @@ import pytest from project.api import RestApi -class Psycog2Error(object): - def __init__(self, pgcode): - self.pgcode = pgcode - - def test_handle_error_unique(app): - from sqlalchemy.exc import IntegrityError - from psycopg2.errorcodes import UNIQUE_VIOLATION + from project.utils import make_unique_violation - orig = Psycog2Error(UNIQUE_VIOLATION) - error = IntegrityError("Select", list(), orig) + error = make_unique_violation() api = RestApi(app) (data, code) = api.handle_error(error) @@ -20,6 +13,28 @@ def test_handle_error_unique(app): assert data["name"] == "Unique Violation" +def test_handle_error_checkViolation(app): + from project.utils import make_check_violation + + error = make_check_violation() + + api = RestApi(app) + (data, code) = api.handle_error(error) + assert code == 400 + assert data["name"] == "Check Violation" + + +def test_handle_error_integrity(app): + from project.utils import make_integrity_error + + error = make_integrity_error("custom") + + api = RestApi(app) + (data, code) = api.handle_error(error) + assert code == 400 + assert data["name"] == "Integrity Error" + + def test_handle_error_httpException(app): from werkzeug.exceptions import InternalServerError @@ -47,6 +62,19 @@ def test_handle_error_unprocessableEntity(app): assert data["errors"][0]["message"] == "Required" +def test_handle_error_validationError(app): + from marshmallow import ValidationError + + args = {"name": ["Required"]} + validation_error = ValidationError(args) + + api = RestApi(app) + (data, code) = api.handle_error(validation_error) + assert code == 422 + assert data["errors"][0]["field"] == "name" + assert data["errors"][0]["message"] == "Required" + + def test_handle_error_unspecificRaises(app): error = Exception() api = RestApi(app) diff --git a/tests/api/test_event.py b/tests/api/test_event.py index 34b7499..d42db07 100644 --- a/tests/api/test_event.py +++ b/tests/api/test_event.py @@ -39,3 +39,317 @@ def test_dates(client, seeder, utils): url = utils.get_url("api_v1_event_dates", id=event_id) utils.get_ok(url) + + +def create_put( + place_id, organizer_id, name="Neuer Name", start="2021-02-07T11:00:00.000Z" +): + return { + "name": name, + "start": start, + "place": {"id": place_id}, + "organizer": {"id": organizer_id}, + } + + +def test_put(client, seeder, utils, app): + user_id, admin_unit_id = seeder.setup_api_access() + event_id = seeder.create_event(admin_unit_id) + place_id = seeder.upsert_default_event_place(admin_unit_id) + organizer_id = seeder.upsert_default_event_organizer(admin_unit_id) + + put = create_put(place_id, organizer_id) + put["rating"] = 10 + put["description"] = "Neue Beschreibung" + put["external_link"] = "http://www.google.de" + put["ticket_link"] = "http://www.yahoo.de" + put["tags"] = "Freizeit, Klönen" + put["kid_friendly"] = True + put["accessible_for_free"] = True + put["age_from"] = 9 + put["age_to"] = 99 + put["target_group_origin"] = "tourist" + put["attendance_mode"] = "online" + put["status"] = "movedOnline" + put["previous_start_date"] = "2021-02-07T10:00:00+01:00" + put["registration_required"] = True + put["booked_up"] = True + put["expected_participants"] = 500 + put["price_info"] = "Erwachsene 5€, Kinder 2€." + put["recurrence_rule"] = "RRULE:FREQ=DAILY;COUNT=7" + + url = utils.get_url("api_v1_event", id=event_id) + response = utils.put_json(url, put) + utils.assert_response_no_content(response) + + with app.app_context(): + from project.models import ( + Event, + EventStatus, + EventTargetGroupOrigin, + EventAttendanceMode, + ) + from project.dateutils import create_berlin_date + + event = Event.query.get(event_id) + assert event.name == "Neuer Name" + assert event.event_place_id == place_id + assert event.organizer_id == organizer_id + assert event.rating == put["rating"] + assert event.description == put["description"] + assert event.external_link == put["external_link"] + assert event.ticket_link == put["ticket_link"] + assert event.tags == put["tags"] + assert event.kid_friendly == put["kid_friendly"] + assert event.accessible_for_free == put["accessible_for_free"] + assert event.age_from == put["age_from"] + assert event.age_to == put["age_to"] + assert event.target_group_origin == EventTargetGroupOrigin.tourist + assert event.attendance_mode == EventAttendanceMode.online + assert event.status == EventStatus.movedOnline + assert event.previous_start_date == create_berlin_date(2021, 2, 7, 10, 0) + assert event.registration_required == put["registration_required"] + assert event.booked_up == put["booked_up"] + assert event.expected_participants == put["expected_participants"] + assert event.price_info == put["price_info"] + assert event.recurrence_rule == put["recurrence_rule"] + + len_dates = len(event.dates) + assert len_dates == 7 + + +def test_put_invalidRecurrenceRule(client, seeder, utils, app): + user_id, admin_unit_id = seeder.setup_api_access() + event_id = seeder.create_event(admin_unit_id) + place_id = seeder.upsert_default_event_place(admin_unit_id) + organizer_id = seeder.upsert_default_event_organizer(admin_unit_id) + + put = create_put(place_id, organizer_id) + put["recurrence_rule"] = "RRULE:FREQ=SCHMAILY;COUNT=7" + + url = utils.get_url("api_v1_event", id=event_id) + response = utils.put_json(url, put) + utils.assert_response_unprocessable_entity(response) + + +def test_put_missingName(client, seeder, utils, app): + user_id, admin_unit_id = seeder.setup_api_access() + event_id = seeder.create_event(admin_unit_id) + place_id = seeder.upsert_default_event_place(admin_unit_id) + organizer_id = seeder.upsert_default_event_organizer(admin_unit_id) + + put = create_put(place_id, organizer_id) + del put["name"] + + url = utils.get_url("api_v1_event", id=event_id) + response = utils.put_json(url, put) + utils.assert_response_unprocessable_entity(response) + + +def test_put_missingPlace(client, seeder, utils, app): + user_id, admin_unit_id = seeder.setup_api_access() + event_id = seeder.create_event(admin_unit_id) + place_id = seeder.upsert_default_event_place(admin_unit_id) + organizer_id = seeder.upsert_default_event_organizer(admin_unit_id) + + put = create_put(place_id, organizer_id) + del put["place"] + + url = utils.get_url("api_v1_event", id=event_id) + response = utils.put_json(url, put) + utils.assert_response_unprocessable_entity(response) + + +def test_put_placeFromAnotherAdminUnit(client, seeder, utils, app): + user_id, admin_unit_id = seeder.setup_api_access() + event_id = seeder.create_event(admin_unit_id) + organizer_id = seeder.upsert_default_event_organizer(admin_unit_id) + + other_admin_unit_id = seeder.create_admin_unit(user_id, "Other Crew") + place_id = seeder.upsert_default_event_place(other_admin_unit_id) + + url = utils.get_url("api_v1_event", id=event_id) + response = utils.put_json(url, create_put(place_id, organizer_id)) + utils.assert_response_bad_request(response) + utils.assert_response_api_error(response, "Check Violation") + + +def test_put_missingOrganizer(client, seeder, utils, app): + user_id, admin_unit_id = seeder.setup_api_access() + event_id = seeder.create_event(admin_unit_id) + place_id = seeder.upsert_default_event_place(admin_unit_id) + organizer_id = seeder.upsert_default_event_organizer(admin_unit_id) + + put = create_put(place_id, organizer_id) + del put["organizer"] + + url = utils.get_url("api_v1_event", id=event_id) + response = utils.put_json(url, put) + utils.assert_response_unprocessable_entity(response) + + +def test_put_organizerFromAnotherAdminUnit(client, seeder, utils, app): + user_id, admin_unit_id = seeder.setup_api_access() + event_id = seeder.create_event(admin_unit_id) + place_id = seeder.upsert_default_event_place(admin_unit_id) + + other_admin_unit_id = seeder.create_admin_unit(user_id, "Other Crew") + organizer_id = seeder.upsert_default_event_organizer(other_admin_unit_id) + + url = utils.get_url("api_v1_event", id=event_id) + response = utils.put_json(url, create_put(place_id, organizer_id)) + utils.assert_response_bad_request(response) + utils.assert_response_api_error(response, "Check Violation") + + +def test_put_invalidDateFormat(client, seeder, utils, app): + user_id, admin_unit_id = seeder.setup_api_access() + event_id = seeder.create_event(admin_unit_id) + place_id = seeder.upsert_default_event_place(admin_unit_id) + organizer_id = seeder.upsert_default_event_organizer(admin_unit_id) + + put = create_put(place_id, organizer_id, start="07.02.2021T11:00:00.000Z") + + url = utils.get_url("api_v1_event", id=event_id) + response = utils.put_json(url, put) + utils.assert_response_unprocessable_entity(response) + + +def test_put_startAfterEnd(client, seeder, utils, app): + user_id, admin_unit_id = seeder.setup_api_access() + event_id = seeder.create_event(admin_unit_id) + place_id = seeder.upsert_default_event_place(admin_unit_id) + organizer_id = seeder.upsert_default_event_organizer(admin_unit_id) + + put = create_put(place_id, organizer_id) + put["start"] = "2021-02-07T11:00:00.000Z" + put["end"] = "2021-02-07T10:59:00.000Z" + + url = utils.get_url("api_v1_event", id=event_id) + response = utils.put_json(url, put) + utils.assert_response_bad_request(response) + + +def test_put_durationMoreThan24Hours(client, seeder, utils, app): + user_id, admin_unit_id = seeder.setup_api_access() + event_id = seeder.create_event(admin_unit_id) + place_id = seeder.upsert_default_event_place(admin_unit_id) + organizer_id = seeder.upsert_default_event_organizer(admin_unit_id) + + put = create_put(place_id, organizer_id) + put["start"] = "2021-02-07T11:00:00.000Z" + put["end"] = "2021-02-08T11:01:00.000Z" + + url = utils.get_url("api_v1_event", id=event_id) + response = utils.put_json(url, put) + utils.assert_response_bad_request(response) + + +def test_put_categories(client, seeder, utils, app): + user_id, admin_unit_id = seeder.setup_api_access() + event_id = seeder.create_event(admin_unit_id) + place_id = seeder.upsert_default_event_place(admin_unit_id) + organizer_id = seeder.upsert_default_event_organizer(admin_unit_id) + category_id = seeder.get_event_category_id("Art") + + put = create_put(place_id, organizer_id) + put["categories"] = [{"id": category_id}] + + url = utils.get_url("api_v1_event", id=event_id) + response = utils.put_json(url, put) + utils.assert_response_no_content(response) + + with app.app_context(): + from project.models import Event + + event = Event.query.get(event_id) + assert event.category.name == "Art" + + +def test_put_dateWithTimezone(client, seeder, utils, app): + from project.dateutils import create_berlin_date + + user_id, admin_unit_id = seeder.setup_api_access() + event_id = seeder.create_event(admin_unit_id) + place_id = seeder.upsert_default_event_place(admin_unit_id) + organizer_id = seeder.upsert_default_event_organizer(admin_unit_id) + + put = create_put(place_id, organizer_id, start="2030-12-31T14:30:00+01:00") + + url = utils.get_url("api_v1_event", id=event_id) + response = utils.put_json(url, put) + utils.assert_response_no_content(response) + + with app.app_context(): + from project.models import Event + + expected = create_berlin_date(2030, 12, 31, 14, 30) + + event = Event.query.get(event_id) + assert event.start == expected + + +def test_put_dateWithoutTimezone(client, seeder, utils, app): + from project.dateutils import create_berlin_date + + user_id, admin_unit_id = seeder.setup_api_access() + event_id = seeder.create_event(admin_unit_id) + place_id = seeder.upsert_default_event_place(admin_unit_id) + organizer_id = seeder.upsert_default_event_organizer(admin_unit_id) + + put = create_put(place_id, organizer_id, start="2030-12-31T14:30:00") + + url = utils.get_url("api_v1_event", id=event_id) + response = utils.put_json(url, put) + utils.assert_response_no_content(response) + + with app.app_context(): + from project.models import Event + + expected = create_berlin_date(2030, 12, 31, 14, 30) + + event = Event.query.get(event_id) + assert event.start == expected + + +def test_patch(client, seeder, utils, app): + user_id, admin_unit_id = seeder.setup_api_access() + event_id = seeder.create_event(admin_unit_id) + + url = utils.get_url("api_v1_event", id=event_id) + response = utils.patch_json(url, {"description": "Neu"}) + utils.assert_response_no_content(response) + + with app.app_context(): + from project.models import Event + + event = Event.query.get(event_id) + assert event.name == "Name" + assert event.description == "Neu" + + +def test_patch_startAfterEnd(client, seeder, utils, app): + user_id, admin_unit_id = seeder.setup_api_access() + event_id = seeder.create_event(admin_unit_id) + + url = utils.get_url("api_v1_event", id=event_id) + response = utils.patch_json(url, {"start": "2021-02-07T11:00:00.000Z"}) + utils.assert_response_no_content(response) + + response = utils.patch_json(url, {"end": "2021-02-07T10:59:00.000Z"}) + utils.assert_response_bad_request(response) + + +def test_delete(client, seeder, utils, app): + user_id, admin_unit_id = seeder.setup_api_access() + event_id = seeder.create_event(admin_unit_id) + + url = utils.get_url("api_v1_event", id=event_id) + response = utils.delete(url) + utils.assert_response_no_content(response) + + with app.app_context(): + from project.models import Event + + event = Event.query.get(event_id) + assert event is None diff --git a/tests/api/test_fields.py b/tests/api/test_fields.py index 560863c..297251f 100644 --- a/tests/api/test_fields.py +++ b/tests/api/test_fields.py @@ -41,7 +41,7 @@ def test_numeric_str_deserialize(latitude, longitude, valid): "longitude": longitude, } - schema = LocationPostRequestSchema(load_instance=True) + schema = LocationPostRequestSchema() if valid: location = schema.load(data) diff --git a/tests/api/test_organization.py b/tests/api/test_organization.py index 4a4635f..d11770b 100644 --- a/tests/api/test_organization.py +++ b/tests/api/test_organization.py @@ -41,9 +41,7 @@ def test_organizers(client, seeder, utils): def test_organizers_post(client, seeder, utils, app): user_id, admin_unit_id = seeder.setup_api_access() - url = utils.get_url( - "api_v1_organization_organizer_list", id=admin_unit_id, name="crew" - ) + url = utils.get_url("api_v1_organization_organizer_list", id=admin_unit_id) response = utils.post_json(url, {"name": "Neuer Organisator"}) utils.assert_response_created(response) assert "id" in response.json @@ -59,6 +57,45 @@ def test_organizers_post(client, seeder, utils, app): assert organizer is not None +def test_events(client, seeder, utils): + user_id, admin_unit_id = seeder.setup_base() + seeder.create_event(admin_unit_id) + + url = utils.get_url("api_v1_organization_event_list", id=admin_unit_id) + utils.get_ok(url) + + +def test_events_post(client, seeder, utils, app): + user_id, admin_unit_id = seeder.setup_api_access() + place_id = seeder.upsert_default_event_place(admin_unit_id) + organizer_id = seeder.upsert_default_event_organizer(admin_unit_id) + + url = utils.get_url("api_v1_organization_event_list", id=admin_unit_id) + response = utils.post_json( + url, + { + "name": "Fest", + "start": "2021-02-07T11:00:00.000Z", + "place": {"id": place_id}, + "organizer": {"id": organizer_id}, + }, + ) + utils.assert_response_created(response) + assert "id" in response.json + + with app.app_context(): + from project.models import Event + + event = ( + Event.query.filter(Event.admin_unit_id == admin_unit_id) + .filter(Event.name == "Fest") + .first() + ) + assert event is not None + assert event.event_place_id == place_id + assert event.organizer_id == organizer_id + + def test_places(client, seeder, utils): user_id, admin_unit_id = seeder.setup_base() seeder.upsert_default_event_place(admin_unit_id) @@ -71,7 +108,13 @@ def test_places_post(client, seeder, utils, app): user_id, admin_unit_id = seeder.setup_api_access() url = utils.get_url("api_v1_organization_place_list", id=admin_unit_id, name="crew") - response = utils.post_json(url, {"name": "Neuer Ort"}) + response = utils.post_json( + url, + { + "name": "Neuer Ort", + "location": {"street": "Straße 1", "postalCode": "38640", "city": "Goslar"}, + }, + ) utils.assert_response_created(response) assert "id" in response.json @@ -84,6 +127,10 @@ def test_places_post(client, seeder, utils, app): .first() ) assert place is not None + assert place.name == "Neuer Ort" + assert place.location.street == "Straße 1" + assert place.location.postalCode == "38640" + assert place.location.city == "Goslar" def test_references_incoming(client, seeder, utils): diff --git a/tests/seeder.py b/tests/seeder.py index 6218ac4..66b680d 100644 --- a/tests/seeder.py +++ b/tests/seeder.py @@ -166,6 +166,12 @@ class Seeder(object): self._utils.authorize(client_id, client_secret, scope) return (user_id, admin_unit_id) + def get_event_category_id(self, category_name): + from project.services.event import get_event_category + + category = get_event_category(category_name) + return category.id + def create_event(self, admin_unit_id, recurrence_rule=None): from project.models import Event from project.services.event import insert_event, upsert_event_category diff --git a/tests/utils.py b/tests/utils.py index c7f45e2..a583ec6 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -169,6 +169,15 @@ class UtilActions(object): def assert_response_no_content(self, response): assert response.status_code == 204 + def assert_response_unprocessable_entity(self, response): + assert response.status_code == 422 + + def assert_response_bad_request(self, response): + assert response.status_code == 400 + + def assert_response_api_error(self, response, message): + assert response.json["name"] == message + def get_unauthorized(self, url): response = self._client.get(url) self.assert_response_unauthorized(response) diff --git a/tests/views/test_event.py b/tests/views/test_event.py index c37d2a9..761a160 100644 --- a/tests/views/test_event.py +++ b/tests/views/test_event.py @@ -186,7 +186,7 @@ def test_create_startAfterEnd(client, app, utils, seeder, mocker): ) -def test_create_DurationMoreThan24Hours(client, app, utils, seeder, mocker): +def test_create_durationMoreThan24Hours(client, app, utils, seeder, mocker): user_id, admin_unit_id = seeder.setup_base() place_id = seeder.upsert_default_event_place(admin_unit_id)