diff --git a/app/api/custom/attendees.py b/app/api/custom/attendees.py index 8ba665edc6..b9f1de1e25 100644 --- a/app/api/custom/attendees.py +++ b/app/api/custom/attendees.py @@ -1,13 +1,16 @@ from flask import Blueprint, abort, jsonify, make_response, request from flask_jwt_extended import current_user +from flask_rest_jsonapi.exceptions import ObjectNotFound from sqlalchemy.orm.exc import NoResultFound +from app.api.helpers.db import safe_query_by_id from app.api.helpers.errors import ForbiddenError, NotFoundError, UnprocessableEntityError from app.api.helpers.mail import send_email_to_attendees from app.api.helpers.permission_manager import has_access from app.api.helpers.permissions import jwt_required from app.models import db from app.models.order import Order +from app.models.ticket_holder import TicketHolder attendee_blueprint = Blueprint('attendee_blueprint', __name__, url_prefix='/v1') @@ -45,3 +48,58 @@ def send_receipt(): return jsonify(message="receipt sent to attendees") else: raise UnprocessableEntityError({'source': ''}, 'Order identifier missing') + + +@attendee_blueprint.route('/states', methods=['GET']) +@jwt_required +def check_attendee_state(): + """ + API to check attendee state is check in/registered + @return: user is registered or not + """ + from app.models.event import Event + + if not request.args.get('event_id', False): + raise NotFoundError( + {'parameter': 'event_id'}, "event_id is missing from your request." + ) + if not request.args.get('attendee_id', False): + raise NotFoundError( + {'parameter': 'attendee_id'}, "attendee_id is missing from your request." + ) + event_id = request.args.get('event_id') + attendee_id = request.args.get('attendee_id') + if event_id is not None: + validate_param_as_id(event_id) + if attendee_id is not None: + validate_param_as_id(attendee_id) + try: + event = safe_query_by_id(Event, event_id) + except ObjectNotFound: + raise NotFoundError({'parameter': f'{event_id}'}, "Event not found.") + try: + attendee = safe_query_by_id(TicketHolder, attendee_id) + except ObjectNotFound: + raise NotFoundError({'parameter': f'{attendee_id}'}, "Attendee not found.") + if event.id != attendee.event_id: + raise UnprocessableEntityError( + {'parameter': 'Attendee'}, + "Attendee not belong to this event.", + ) + return jsonify( + { + 'is_registered': attendee.is_registered, + 'register_times': attendee.register_times, + } + ) + + +def validate_param_as_id(param): + """ + validate id if integer or not + @param param: param to check + """ + if not (isinstance(param, int) or (isinstance(param, str) and param.isdigit())): + raise UnprocessableEntityError( + {'parameter': f'{param}'}, f'{param} is not a valid id' + ) diff --git a/app/api/schema/attendees.py b/app/api/schema/attendees.py index c74a78962a..d398dee96d 100644 --- a/app/api/schema/attendees.py +++ b/app/api/schema/attendees.py @@ -70,6 +70,8 @@ def validate_json(self, data, original_data): checkout_times = fields.Str(allow_none=True) attendee_notes = fields.Str(allow_none=True) is_checked_out = fields.Boolean() + is_registered = fields.Boolean() + register_times = fields.Str(allow_none=True) pdf_url = fields.Url(dump_only=True) complex_field_values = CustomFormValueField(allow_none=True) is_consent_form_field = fields.Boolean(allow_none=True) diff --git a/app/api/user_check_in.py b/app/api/user_check_in.py index 31277adb79..fb0c6ab090 100644 --- a/app/api/user_check_in.py +++ b/app/api/user_check_in.py @@ -4,6 +4,7 @@ from flask_rest_jsonapi.exceptions import ObjectNotFound from sqlalchemy.orm.exc import NoResultFound +from app.api.helpers.db import save_to_db from app.api.helpers.errors import UnprocessableEntityError from app.api.helpers.permission_manager import has_access from app.api.helpers.permissions import jwt_required @@ -19,6 +20,7 @@ from app.models.session import Session from app.models.session_type import SessionType from app.models.station import Station +from app.models.ticket_holder import TicketHolder from app.models.track import Track from app.models.user_check_in import UserCheckIn @@ -95,28 +97,57 @@ def before_create_object(self, data, _view_kwargs): :param _view_kwargs: :return: """ - station = self.session.query(Station).filter_by(id=data.get('station')).one() + try: + station = db.session.query(Station).filter_by(id=data.get('station')).one() + except NoResultFound: + raise ObjectNotFound({'parameter': data.get('station')}, "Station: not found") + current_time = datetime.datetime.utcnow() if not has_access('is_coorganizer', event_id=station.event_id): raise UnprocessableEntityError( {'parameter': 'station'}, "Only admin/organiser/coorganizer of event only able to check in", ) + try: + attendee = ( + self.session.query(TicketHolder) + .filter_by(id=data.get('ticket_holder')) + .one() + ) + except NoResultFound: + raise ObjectNotFound( + {'parameter': data.get('attendee')}, "Attendee: not found" + ) + + if attendee.event_id != station.event_id: + raise UnprocessableEntityError( + {'parameter': 'Attendee'}, + "Attendee not belong to this event", + ) + if station.station_type != STATION_TYPE.get('registration'): # validate if microlocation_id from session matches with station - session = self.session.query(Session).filter_by(id=data.get('session')).one() + session = ( + self.session.query(Session).filter_by(id=data.get('session')).first() + ) + if session is None: + raise ObjectNotFound( + {'parameter': data.get('session')}, "Session: not found" + ) validate_microlocation(station=station, session=session) if session.session_type_id: session_type = ( self.session.query(SessionType) .filter(SessionType.id == session.session_type_id) - .one() + .first() ) - data['session_name'] = session_type.name + if session_type is not None: + data['session_name'] = session_type.name if session.track_id: track = ( - self.session.query(Track).filter(Track.id == session.track_id).one() + self.session.query(Track).filter(Track.id == session.track_id).first() ) - data['track_name'] = track.name + if track is not None: + data['track_name'] = track.name data['speaker_name'] = ', '.join( [str(speaker.name) for speaker in session.speakers] ) @@ -139,6 +170,7 @@ def before_create_object(self, data, _view_kwargs): validate_check_in_out_status( station=station, attendee_data=attendee_check_in_status ) + data['check_in_out_at'] = current_time else: if station.station_type == STATION_TYPE.get('registration'): attendee_check_in_status = ( @@ -158,6 +190,10 @@ def before_create_object(self, data, _view_kwargs): }, "Attendee already registered.", ) + # update register time for attendee + attendee.is_registered = True + attendee.register_times = current_time + save_to_db(attendee) if station.station_type == STATION_TYPE.get('daily'): attendee_check_in_status = ( self.session.query(UserCheckIn) @@ -178,12 +214,6 @@ def before_create_object(self, data, _view_kwargs): "Attendee already check daily on station.", ) - if station.station_type in ( - STATION_TYPE.get('check in'), - STATION_TYPE.get('check out'), - ): - data['check_in_out_at'] = datetime.datetime.utcnow() - schema = UserCheckInSchema methods = [ 'POST', diff --git a/app/models/ticket_holder.py b/app/models/ticket_holder.py index 3c956428c1..dc12ef6c55 100644 --- a/app/models/ticket_holder.py +++ b/app/models/ticket_holder.py @@ -54,9 +54,11 @@ class TicketHolder(SoftDeletionModel): order_id: int = db.Column(db.Integer, db.ForeignKey('orders.id', ondelete='CASCADE')) is_checked_in: bool = db.Column(db.Boolean, default=False) is_checked_out: bool = db.Column(db.Boolean, default=False) + is_registered: bool = db.Column(db.Boolean, default=False) device_name_checkin: str = db.Column(db.String) checkin_times: str = db.Column(db.String) checkout_times: str = db.Column(db.String) + register_times: str = db.Column(db.String) attendee_notes: str = db.Column(db.String) event_id: int = db.Column( db.Integer, db.ForeignKey('events.id', ondelete='CASCADE'), nullable=False diff --git a/docs/api/blueprint/attendees.apib b/docs/api/blueprint/attendees.apib index 03c48c35a0..d565ecc374 100644 --- a/docs/api/blueprint/attendees.apib +++ b/docs/api/blueprint/attendees.apib @@ -15,6 +15,8 @@ Related to ticket holders(attendees) of an event (free, paid, donation) to the e | `is-checked-out` | If the attendee has checked out | boolean | - | | `attendee-notes` | Comma separated attendee notes | string | - | | `pdf-url` | pdf url of the Attendee | url | - | +| `is-registered` | If the attendee is registered | boolean | - | +| `register-times` | Comma separated register times | string | - | ## Send order receipts [v1/attendees/send-receipt] @@ -109,6 +111,7 @@ Get a list of attendees of an order. "deleted-at": null, "work-address": null, "checkin-times": null, + "register-times": null, "state": "example", "country": "IN", "lastname": "UnDoe", @@ -116,6 +119,7 @@ Get a list of attendees of an order. "phone": null, "company": null, "is-checked-in": false, + "is-registered": false, "gender": null, "shipping-address": null, "blog": null, @@ -146,7 +150,7 @@ Get a list of attendees of an order. "self": "/v1/orders/7201904e-c695-4251-a30a-61765a37ff24/attendees" } } - + ## List Attendees under an event [/v1/events/{event_id}/attendees] + Parameters + event_id: 1 (integer) - Identifier of the event @@ -206,6 +210,7 @@ Get a list of attendees of an event. "deleted-at": null, "work-address": null, "checkin-times": null, + "register-times": null, "state": "example", "country": "IN", "lastname": "UnDoe", @@ -213,6 +218,7 @@ Get a list of attendees of an event. "phone": null, "company": null, "is-checked-in": false, + "is-registered": false, "gender": null, "shipping-address": null, "blog": null, @@ -279,6 +285,8 @@ Search a list of attendees of an event. "phone": null, "company": null, "is-checked-in": false, + "is-registered": false, + "register-times": null, "gender": null, "shipping-address": null, "blog": null, @@ -364,6 +372,8 @@ Get a list of attendees of a ticket. "phone": null, "company": null, "is-checked-in": false, + "is-registered": false, + "register-times": null, "gender": null, "shipping-address": null, "blog": null, @@ -445,6 +455,8 @@ Get a single attendee. "phone": null, "company": null, "is-checked-in": false, + "is-registered": false, + "register-times": null, "gender": null, "shipping-address": null, "blog": null, @@ -589,4 +601,26 @@ Delete a single attendee. "version": "1.0" } } - \ No newline at end of file + +## Get Attendee State [/v1/states{?event_id,attendee_id}] ++ Parameters + + event_id: 1 (integer) - Identifier of the event + + attendee_id: 1 (integer) - ID of the attendee in the form of an integer + +### Get Attendee State [GET] +Check attendee state if attendee is registered or not. + ++ Request + + + Headers + + Accept: application/vnd.api+json + + Authorization: JWT + ++ Response 200 (application/json) + + { + "is_registered": true, + "register_times": "2023-08-08 03:03:52.827812" + } diff --git a/migrations/versions/rev-2023-08-07-15:52:49-3e8e18c0bebe_.py b/migrations/versions/rev-2023-08-07-15:52:49-3e8e18c0bebe_.py new file mode 100644 index 0000000000..875768ed45 --- /dev/null +++ b/migrations/versions/rev-2023-08-07-15:52:49-3e8e18c0bebe_.py @@ -0,0 +1,29 @@ +"""empty message + +Revision ID: 3e8e18c0bebe +Revises: 8b5bc48e1d4c +Create Date: 2023-08-07 15:52:49.656233 + +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '3e8e18c0bebe' +down_revision = '8b5bc48e1d4c' + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('ticket_holders', sa.Column('is_registered', sa.Boolean(), server_default='False', nullable=True)) + op.add_column('ticket_holders', sa.Column('register_times', sa.String(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('ticket_holders', 'register_times') + op.drop_column('ticket_holders', 'is_registered') + # ### end Alembic commands ### diff --git a/tests/all/integration/api/attendee/test_attendee_state.py b/tests/all/integration/api/attendee/test_attendee_state.py new file mode 100644 index 0000000000..6db065ad0c --- /dev/null +++ b/tests/all/integration/api/attendee/test_attendee_state.py @@ -0,0 +1,102 @@ +import json + +from tests.factories.attendee import AttendeeOrderTicketSubFactory, AttendeeSubFactory +from tests.factories.event import EventFactoryBasic +from tests.factories.microlocation import MicrolocationSubFactory +from tests.factories.session import SessionSubFactory +from tests.factories.station import StationSubFactory +from tests.factories.ticket import TicketSubFactory + + +def get_minimal_attendee(db, user): + """ + create attendee + @param db: db + @param user: user + @return: attendee created + """ + attendee = AttendeeOrderTicketSubFactory( + email=None, address=None, city=None, state=None, country=None, order__user=user + ) + db.session.commit() + + return attendee + + +def test_attendee_not_register_yet(db, client, jwt, user): + """ + Testing for case attendee not register yet + @param db: db + @param client: client + @param jwt: jwt + @param user: user + """ + attendee = get_minimal_attendee(db, user) + data = {'event_id': attendee.event_id, 'attendee_id': attendee.id} + response = client.get( + '/v1/states', + content_type='application/json', + headers=jwt, + query_string=data, + ) + assert response.status_code == 200 + assert json.loads(response.data)['is_registered'] is False + + +def test_attendee_registered(db, client, jwt, user): + """ + Test user is already registered + @param db: db + @param client: client + @param jwt: jwt + @param user: user + """ + user.is_super_admin = True + event = EventFactoryBasic() + microlocation = MicrolocationSubFactory(event=event) + ticket = TicketSubFactory(event=event) + station = StationSubFactory( + event=event, microlocation=microlocation, station_type='registration' + ) + session = SessionSubFactory( + event=event, + microlocation=microlocation, + ) + attendee = AttendeeSubFactory( + event=event, + ticket=ticket, + ) + db.session.commit() + data = json.dumps( + { + "data": { + "type": "user_check_in", + "attributes": {}, + "relationships": { + "station": {"data": {"id": str(station.id), "type": "station"}}, + "session": {"data": {"id": str(session.id), "type": "session"}}, + "ticket_holder": { + "data": {"id": str(attendee.id), "type": "attendee"} + }, + }, + } + } + ) + + client.post( + '/v1/user-check-in', + content_type='application/vnd.api+json', + headers=jwt, + data=data, + ) + + data = {'event_id': event.id, 'attendee_id': attendee.id} + + response = client.get( + '/v1/states', + content_type='application/json', + headers=jwt, + query_string=data, + ) + assert response.status_code == 200 + assert json.loads(response.data)['is_registered'] is True diff --git a/tests/all/integration/api/users_check_in/test_users_check_in_api.py b/tests/all/integration/api/users_check_in/test_users_check_in_api.py index ccdfe631b8..129932e010 100644 --- a/tests/all/integration/api/users_check_in/test_users_check_in_api.py +++ b/tests/all/integration/api/users_check_in/test_users_check_in_api.py @@ -4,7 +4,7 @@ from tests.factories.event import EventFactoryBasic from tests.factories.microlocation import MicrolocationSubFactory from tests.factories.session import SessionSubFactory -from tests.factories.station import StationFactory +from tests.factories.station import StationSubFactory from tests.factories.ticket import TicketFactory @@ -18,7 +18,7 @@ def test_create_station_from_user_check_in(db, client, jwt, user): ticket = TicketFactory( event=event, ) - station = StationFactory( + station = StationSubFactory( event=event, microlocation=microlocation, station_type='registration' ) session = SessionSubFactory( diff --git a/tests/hook_main.py b/tests/hook_main.py index a94fa26fd3..9164c21b4b 100644 --- a/tests/hook_main.py +++ b/tests/hook_main.py @@ -50,11 +50,11 @@ from tests.factories.sponsor import SponsorFactory from tests.factories.speakers_call import SpeakersCallFactory from tests.factories.tax import TaxFactory -from tests.factories.station import StationFactory +from tests.factories.station import StationFactory, StationSubFactory from tests.factories.station_store_pax import StationStorePaxFactory from tests.factories.session import SessionFactory, SessionFactoryBasic, SessionSubFactory from tests.factories.speaker import SpeakerFactory -from tests.factories.ticket import TicketFactory +from tests.factories.ticket import TicketFactory, TicketSubFactory from tests.factories.attendee import ( AttendeeFactory, AttendeeOrderSubFactory, @@ -89,7 +89,6 @@ _create_taxed_tickets, ) - stash = {} api_username = "open_event_test_user@fossasia.org" api_password = "fossasia" @@ -522,7 +521,7 @@ def group_event_get_list(transaction): :return: """ with stash['app'].app_context(): - event = EventFactoryBasic() + EventFactoryBasic() group = GroupFactory() db.session.add(group) db.session.commit() @@ -829,7 +828,7 @@ def group_get_list(transaction): :return: """ with stash['app'].app_context(): - event = EventFactoryBasic() + EventFactoryBasic() group = GroupFactory() db.session.add(group) db.session.commit() @@ -843,7 +842,7 @@ def group_get_list_from_user(transaction): :return: """ with stash['app'].app_context(): - event = EventFactoryBasic() + EventFactoryBasic() group = GroupFactory() db.session.add(group) db.session.commit() @@ -857,7 +856,7 @@ def group_post(transaction): :return: """ with stash['app'].app_context(): - event = EventFactoryBasic() + EventFactoryBasic() group = GroupFactory() db.session.add(group) db.session.commit() @@ -871,7 +870,7 @@ def group_get_detail(transaction): :return: """ with stash['app'].app_context(): - event = EventFactoryBasic() + EventFactoryBasic() group = GroupFactory() db.session.add(group) db.session.commit() @@ -902,7 +901,7 @@ def group_patch(transaction): :return: """ with stash['app'].app_context(): - event = EventFactoryBasic() + EventFactoryBasic() group = GroupFactory() db.session.add(group) db.session.commit() @@ -916,7 +915,7 @@ def group_delete(transaction): :return: """ with stash['app'].app_context(): - event = EventFactoryBasic() + EventFactoryBasic() group = GroupFactory() db.session.add(group) db.session.commit() @@ -2181,6 +2180,35 @@ def get_attendees_from_ticket(transaction): db.session.commit() +@hooks.before("Attendees > Get Attendee State > Get Attendee State") +def get_attendee_state(transaction): + """ + GET /v1/states{?event_id,attendee_id} + :param transaction: + :return: + """ + with stash['app'].app_context(): + event = EventFactoryBasic() + microlocation = MicrolocationSubFactory( + event=event, + ) + ticket = TicketSubFactory( + event=event, + ) + StationSubFactory( + event=event, microlocation=microlocation, station_type='registration' + ) + SessionSubFactory( + event=event, + microlocation=microlocation, + ) + AttendeeSubFactory( + event=event, + ticket=ticket, + ) + db.session.commit() + + # ------------------------- Tracks ------------------------- @hooks.before("Tracks > Tracks Collection > Create Track") def track_post(transaction): @@ -5139,7 +5167,7 @@ def create_user_check_in(transaction): ticket = TicketFactory( event=event, ) - StationFactory( + StationSubFactory( event=event, microlocation=microlocation, station_type='registration' ) SessionSubFactory(