Repository: fossasia/open-event-server

Issue:
Multiple ticket_holders can be mapped to the same ticket
**Describe the bug**
Server allows multiple ticket holders to have the same ticket. However, only one ticket holder should be mapped to a given ticket.

**To Reproduce**
1. Create a ticket (`POST`)
2. Create multiple attendees all having the ticket id of the above ticket (`POST`)

**Expected behavior**
Server should validate that each ticket corresponds to *one and only one* attendee.

I'm working on fixing this.


Discussion:

Resolving patch:
diff --git a/app/api/attendees.py b/app/api/attendees.py
--- a/app/api/attendees.py
+++ b/app/api/attendees.py
@@ -3,7 +3,11 @@
 
 from app.api.bootstrap import api
 from app.api.helpers.db import safe_query
-from app.api.helpers.exceptions import ForbiddenException, UnprocessableEntity
+from app.api.helpers.exceptions import (
+    ConflictException,
+    ForbiddenException,
+    UnprocessableEntity,
+)
 from app.api.helpers.permission_manager import has_access
 from app.api.helpers.permissions import jwt_required
 from app.api.helpers.query import event_query
@@ -31,11 +35,26 @@ def before_post(self, args, kwargs, data):
         """
         require_relationship(['ticket', 'event'], data)
 
-        ticket = db.session.query(Ticket).filter_by(id=int(data['ticket'])).first()
+        ticket = db.session.query(Ticket).filter_by(
+            id=int(data['ticket'])
+            ).first()
         if ticket is None:
-            raise UnprocessableEntity({'pointer': '/data/relationships/ticket'}, "Invalid Ticket")
+            raise UnprocessableEntity(
+                {'pointer': '/data/relationships/ticket'}, "Invalid Ticket"
+                )
         if ticket.event_id != int(data['event']):
-            raise UnprocessableEntity({'pointer': '/data/relationships/ticket'}, "Ticket belongs to a different Event")
+            raise UnprocessableEntity(
+                {'pointer': '/data/relationships/ticket'},
+                "Ticket belongs to a different Event"
+            )
+
+        if db.session.query(TicketHolder.id).filter_by(
+                ticket_id=int(data['ticket']), event_id=int(data['event'])
+                ).scalar() is not None:
+            raise ConflictException(
+                {'pointer': '/data/attributes/ticket_id'},
+                "Attendee with this ticket already exists for the same event"
+            )
 
     decorators = (jwt_required,)
     methods = ['POST']
diff --git a/app/models/ticket_holder.py b/app/models/ticket_holder.py
--- a/app/models/ticket_holder.py
+++ b/app/models/ticket_holder.py
@@ -8,6 +8,9 @@
 
 class TicketHolder(db.Model):
     __tablename__ = "ticket_holders"
+    __table_args__ = (
+        db.UniqueConstraint('ticket_id', name='ticket_event'),
+        )
 
     id = db.Column(db.Integer, primary_key=True)
     firstname = db.Column(db.String, nullable=False)
diff --git a/migrations/versions/9d21de792967_.py b/migrations/versions/9d21de792967_.py
new file mode 100644
--- /dev/null
+++ b/migrations/versions/9d21de792967_.py
@@ -0,0 +1,29 @@
+"""empty message
+
+Revision ID: 9d21de792967
+Revises: 49f3a33f5437
+Create Date: 2018-06-05 20:03:20.588368
+
+"""
+
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision = '9d21de792967'
+down_revision = '49f3a33f5437'
+
+
+def upgrade():
+    """commands auto generated by Alembic - please adjust!"""
+    op.execute(
+        """DELETE FROM ticket_holders WHERE id NOT IN
+        (SELECT min(id) FROM ticket_holders GROUP BY ticket_id)
+        """)
+    op.create_unique_constraint('ticket_event', 'ticket_holders', ['ticket_id'])
+    # ### end Alembic commands ###
+
+
+def downgrade():
+    """commands auto generated by Alembic - please adjust!"""
+    op.drop_constraint('ticket_event', 'ticket_holders', type_='unique')
+    # ### end Alembic commands ###


Test patch: