diff --git a/project/__init__.py b/project/__init__.py index 9b22a5f..7fb0582 100644 --- a/project/__init__.py +++ b/project/__init__.py @@ -10,6 +10,11 @@ from flask_cors import CORS from flask_qrcode import QRcode from flask_mail import Mail, email_dispatched from flask_migrate import Migrate +from flask_marshmallow import Marshmallow +from flask_restful import Api +from apispec import APISpec +from apispec.ext.marshmallow import MarshmallowPlugin +from flask_apispec.extension import FlaskApiSpec # Create app app = Flask(__name__) @@ -42,6 +47,21 @@ babel = Babel(app) # cors cors = CORS(app, resources={r"/api/*": {"origins": "*"}}) +# API +rest_api = Api(app, "/api/v1") +marshmallow = Marshmallow(app) +app.config.update( + { + "APISPEC_SPEC": APISpec( + title="Oveda API", + version="1.0.0", + plugins=[MarshmallowPlugin()], + openapi_version="2.0", + ), + } +) +api_docs = FlaskApiSpec(app) + # Mail mail_server = os.getenv("MAIL_SERVER") @@ -114,6 +134,9 @@ from project.views import ( widget, ) +# API Resources +import project.api + # Command line import project.cli.event import project.cli.user diff --git a/project/api/__init__.py b/project/api/__init__.py new file mode 100644 index 0000000..df7f796 --- /dev/null +++ b/project/api/__init__.py @@ -0,0 +1,3 @@ +import project.api.event.resources +import project.api.organization.resources +import project.api.organizer.resources diff --git a/project/api/event/__init__.py b/project/api/event/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/project/api/event/resources.py b/project/api/event/resources.py new file mode 100644 index 0000000..e1f69a0 --- /dev/null +++ b/project/api/event/resources.py @@ -0,0 +1,32 @@ +from project import rest_api, api_docs +from flask_apispec import marshal_with, doc, use_kwargs +from flask_apispec.views import MethodResource +from project.api.event.schemas import ( + EventSchema, + EventListRequestSchema, + EventListResponseSchema, +) +from project.models import Event + + +class EventListResource(MethodResource): + @doc(tags=["Events"]) + @use_kwargs(EventListRequestSchema, location=("query")) + @marshal_with(EventListResponseSchema) + def get(self, **kwargs): + pagination = Event.query.paginate() + return pagination + + +class EventResource(MethodResource): + @doc(tags=["Events"]) + @marshal_with(EventSchema) + def get(self, id): + return Event.query.get_or_404(id) + + +rest_api.add_resource(EventListResource, "/events") +api_docs.register(EventListResource) + +rest_api.add_resource(EventResource, "/events/") +api_docs.register(EventResource) diff --git a/project/api/event/schemas.py b/project/api/event/schemas.py new file mode 100644 index 0000000..3b17bed --- /dev/null +++ b/project/api/event/schemas.py @@ -0,0 +1,79 @@ +from project import marshmallow +from marshmallow import fields +from project.models import Event + + +class EventSchema(marshmallow.SQLAlchemySchema): + class Meta: + model = Event + + id = 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() + + organization = marshmallow.HyperlinkRelated( + "organizationresource", attribute="admin_unit" + ) + organizer = marshmallow.URLFor( + "organizerresource", + values=dict(organization_id="", organizer_id=""), + ) + + +class EventListItemSchema(marshmallow.SQLAlchemySchema): + class Meta: + model = Event + + id = marshmallow.auto_field() + href = marshmallow.URLFor("eventresource", values=dict(id="")) + name = marshmallow.auto_field() + start = marshmallow.auto_field() + end = marshmallow.auto_field() + recurrence_rule = marshmallow.auto_field() + + +class EventListRequestSchema(marshmallow.Schema): + page = fields.Integer( + required=False, + default=1, + metadata={"description": "The page number (1 indexed)."}, + ) + per_page = fields.Integer( + required=False, default=20, metadata={"description": "Items per page"} + ) + + +class EventListResponseSchema(marshmallow.Schema): + has_next = fields.Boolean( + required=True, metadata={"description": "True if a next page exists."} + ) + has_prev = fields.Boolean( + required=True, metadata={"description": "True if a previous page exists."} + ) + next_num = fields.Integer( + required=False, metadata={"description": "Number of the next page."} + ) + prev_num = fields.Integer( + required=False, metadata={"description": "Number of the previous page."} + ) + page = fields.Integer( + required=True, metadata={"description": "The current page number (1 indexed)."} + ) + pages = fields.Integer( + required=True, metadata={"description": "The total number of pages."} + ) + per_page = fields.Integer(required=True, metadata={"description": "Items per page"}) + total = fields.Integer( + required=True, + metadata={"description": "The total number of items matching the query"}, + ) + items = fields.List( + fields.Nested(EventListItemSchema), metadata={"description": "Events"} + ) diff --git a/project/api/organization/__init__.py b/project/api/organization/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/project/api/organization/resources.py b/project/api/organization/resources.py new file mode 100644 index 0000000..cbe0a48 --- /dev/null +++ b/project/api/organization/resources.py @@ -0,0 +1,16 @@ +from project import rest_api, api_docs +from flask_apispec import marshal_with, doc +from flask_apispec.views import MethodResource +from project.api.organization.schemas import OrganizationSchema +from project.models import AdminUnit + + +class OrganizationResource(MethodResource): + @doc(tags=["Organizations"]) + @marshal_with(OrganizationSchema) + def get(self, id): + return AdminUnit.query.get_or_404(id) + + +rest_api.add_resource(OrganizationResource, "/organizations/") +api_docs.register(OrganizationResource) diff --git a/project/api/organization/schemas.py b/project/api/organization/schemas.py new file mode 100644 index 0000000..cf72552 --- /dev/null +++ b/project/api/organization/schemas.py @@ -0,0 +1,14 @@ +from project import marshmallow +from project.models import AdminUnit + + +class OrganizationSchema(marshmallow.SQLAlchemySchema): + class Meta: + model = AdminUnit + + id = marshmallow.auto_field() + name = marshmallow.auto_field() + url = marshmallow.auto_field() + email = marshmallow.auto_field() + phone = marshmallow.auto_field() + fax = marshmallow.auto_field() diff --git a/project/api/organizer/__init__.py b/project/api/organizer/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/project/api/organizer/resources.py b/project/api/organizer/resources.py new file mode 100644 index 0000000..76da9ea --- /dev/null +++ b/project/api/organizer/resources.py @@ -0,0 +1,19 @@ +from project import rest_api, api_docs +from flask_apispec import marshal_with, doc +from flask_apispec.views import MethodResource +from project.api.organizer.schemas import OrganizerSchema +from project.models import EventOrganizer + + +class OrganizerResource(MethodResource): + @doc(tags=["Organizers"]) + @marshal_with(OrganizerSchema) + def get(self, organization_id, organizer_id): + return EventOrganizer.query.get_or_404(organizer_id) + + +rest_api.add_resource( + OrganizerResource, + "/organizations//organizers/", +) +api_docs.register(OrganizerResource) diff --git a/project/api/organizer/schemas.py b/project/api/organizer/schemas.py new file mode 100644 index 0000000..f913405 --- /dev/null +++ b/project/api/organizer/schemas.py @@ -0,0 +1,10 @@ +from project import marshmallow +from project.models import EventOrganizer + + +class OrganizerSchema(marshmallow.SQLAlchemySchema): + class Meta: + model = EventOrganizer + + id = marshmallow.auto_field() + name = marshmallow.auto_field() diff --git a/project/models.py b/project/models.py index 1e2907c..6cdc33d 100644 --- a/project/models.py +++ b/project/models.py @@ -29,7 +29,7 @@ from sqlalchemy import and_ def _current_user_id_or_none(): - if current_user.is_authenticated: + if current_user and current_user.is_authenticated: return current_user.id return None diff --git a/requirements.txt b/requirements.txt index 19fbda8..ca77ed4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,7 @@ alembic==1.4.3 +aniso8601==8.1.0 +apispec==4.0.0 +apispec-webframeworks==0.5.2 appdirs==1.4.4 attrs==20.3.0 Babel==2.9.0 @@ -22,15 +25,18 @@ email-validator==1.1.2 filelock==3.0.12 flake8==3.8.4 Flask==1.1.2 +flask-apispec==0.11.0 Flask-BabelEx==0.9.4 Flask-Bootstrap==3.3.7.1 Flask-Cors==3.0.9 Flask-Dance==3.2.0 Flask-Login==0.5.0 Flask-Mail==0.9.1 +flask-marshmallow==0.14.0 Flask-Migrate==2.5.3 Flask-Principal==0.4.0 Flask-QRcode==3.0.0 +Flask-RESTful==0.3.8 Flask-Security-Too==3.4.4 Flask-SQLAlchemy==2.4.4 Flask-WTF==0.14.3 @@ -42,9 +48,13 @@ importlib-metadata==3.1.1 iniconfig==1.1.1 itsdangerous==1.1.0 Jinja2==2.11.2 +jsonschema==3.2.0 Mako==1.1.3 MarkupSafe==1.1.1 +marshmallow==3.10.0 +marshmallow-sqlalchemy==0.24.1 mccabe==0.6.1 +mistune==0.8.4 mypy-extensions==0.4.3 nodeenv==1.5.0 oauthlib==3.1.0 @@ -60,6 +70,7 @@ pycodestyle==2.6.0 pycparser==2.20 pyflakes==2.2.0 pyparsing==2.4.7 +pyrsistent==0.17.3 pytest==6.1.2 pytest-cov==2.10.1 pytest-mock==3.3.1 @@ -78,6 +89,7 @@ soupsieve==2.1 speaklater==1.3 SQLAlchemy==1.3.20 SQLAlchemy-Utils==0.36.8 +swagger-spec-validator==2.7.3 toml==0.10.2 typed-ast==1.4.1 typing-extensions==3.7.4.3 @@ -85,6 +97,7 @@ urllib3==1.26.2 URLObject==2.4.3 virtualenv==20.2.2 visitor==0.1.3 +webargs==7.0.1 Werkzeug==1.0.1 WTForms==2.3.3 WTForms-SQLAlchemy==0.2 diff --git a/tests/api/__init__.py b/tests/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/api/test_event.py b/tests/api/test_event.py new file mode 100644 index 0000000..61e197b --- /dev/null +++ b/tests/api/test_event.py @@ -0,0 +1,14 @@ +def test_read(client, seeder, utils): + user_id, admin_unit_id = seeder.setup_base() + event_id = seeder.create_event(admin_unit_id) + + url = utils.get_url("eventresource", id=event_id) + utils.get_ok(url) + + +def test_list(client, seeder, utils): + user_id, admin_unit_id = seeder.setup_base() + seeder.create_event(admin_unit_id) + + url = utils.get_url("eventlistresource") + utils.get_ok(url) diff --git a/tests/api/test_organization.py b/tests/api/test_organization.py new file mode 100644 index 0000000..6adf577 --- /dev/null +++ b/tests/api/test_organization.py @@ -0,0 +1,5 @@ +def test_read(client, seeder, utils): + user_id, admin_unit_id = seeder.setup_base() + + url = utils.get_url("organizationresource", id=admin_unit_id) + utils.get_ok(url) diff --git a/tests/api/test_organizer.py b/tests/api/test_organizer.py new file mode 100644 index 0000000..2d84591 --- /dev/null +++ b/tests/api/test_organizer.py @@ -0,0 +1,8 @@ +def test_read(client, seeder, utils): + user_id, admin_unit_id = seeder.setup_base() + organizer_id = seeder.upsert_default_event_organizer(admin_unit_id) + + url = utils.get_url( + "organizerresource", organization_id=admin_unit_id, organizer_id=organizer_id + ) + utils.get_ok(url) diff --git a/tests/api/test_swagger.py b/tests/api/test_swagger.py new file mode 100644 index 0000000..2e74dd0 --- /dev/null +++ b/tests/api/test_swagger.py @@ -0,0 +1,10 @@ +from swagger_spec_validator import validator20 + + +def test_swagger(utils): + response = utils.get_ok("/swagger/") + validator20.validate_spec(response.json) + + +def test_swagger_ui(utils): + utils.get_ok("/swagger-ui/")