mirror of
https://github.com/lucaspalomodevelop/eventcally.git
synced 2026-03-13 00:07:22 +00:00
API Write Access with OAuth2 #104
This commit is contained in:
parent
041571cd28
commit
c5c14f9675
@ -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")
|
||||
@ -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",
|
||||
|
||||
@ -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"])
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -5,6 +5,7 @@ from project.models import Image
|
||||
class ImageIdSchema(marshmallow.SQLAlchemySchema):
|
||||
class Meta:
|
||||
model = Image
|
||||
load_instance = True
|
||||
|
||||
id = marshmallow.auto_field()
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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/<int:id>/events/search",
|
||||
"api_v1_organization_event_search",
|
||||
)
|
||||
add_api_resource(
|
||||
OrganizationEventListResource,
|
||||
"/organizations/<int:id>/events",
|
||||
"api_v1_organization_event_list",
|
||||
)
|
||||
add_api_resource(OrganizationListResource, "/organizations", "api_v1_organization_list")
|
||||
add_api_resource(
|
||||
OrganizationOrganizerListResource,
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user