aboutsummaryrefslogtreecommitdiffstats
path: root/backend/routes/forms/form.py
blob: 1cd9fdecd05a31f72b1d1ac7bd57ab8667d87141 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
"""Returns, updates or deletes a single form given an ID."""

import enum
import json.decoder

import deepmerge
from pydantic import BaseModel, ValidationError
from spectree.response import Response
from starlette.authentication import requires
from starlette.requests import Request
from starlette.responses import JSONResponse

from backend import constants, discord
from backend.models import Form
from backend.route import Route
from backend.routes.forms.discover import AUTH_FORM
from backend.validation import ErrorMessage, OkayResponse, api

PUBLIC_FORM_FEATURES = (constants.FormFeatures.OPEN, constants.FormFeatures.DISCOVERABLE)


class SubmissionPrecheckSeverity(enum.StrEnum):
    SECONDARY = "secondary"
    WARNING = "warning"
    DANGER = "danger"


class SubmissionProblem(BaseModel):
    severity: SubmissionPrecheckSeverity
    message: str


class SubmissionPrecheck(BaseModel):
    problems: list[SubmissionProblem] = []
    can_submit: bool = True


class FormWithAncillaryData(Form):
    """
    Form model with ancillary data for the form.

    This is used to return the form with additional information such as
    whether the user has edit access or not.
    """

    submission_precheck: SubmissionPrecheck = SubmissionPrecheck()


class SingleForm(Route):
    """
    Returns, updates or deletes a single form given an ID.

    Returns all fields for admins, otherwise only public fields.
    """

    name = "form"
    path = "/{form_id:str}"

    @api.validate(
        resp=Response(HTTP_200=FormWithAncillaryData, HTTP_404=ErrorMessage), tags=["forms"]
    )
    async def get(self, request: Request) -> JSONResponse:
        """Returns single form information by ID."""
        form_id = request.path_params["form_id"].lower()

        if form_id == AUTH_FORM.id:
            # Empty form for login purposes
            data = AUTH_FORM.dict(admin=False)
            # Add in empty ancillary data
            data["submission_precheck"] = SubmissionPrecheck().dict()
            return JSONResponse(data)

        try:
            await discord.verify_edit_access(form_id, request)
            admin = True
        except discord.FormNotFoundError:
            return JSONResponse({"error": "not_found"}, status_code=404)
        except discord.UnauthorizedError:
            admin = False

        filters = {
            "_id": form_id,
        }

        if not admin:
            filters["features"] = {"$in": ["OPEN", "DISCOVERABLE"]}

        form = await request.state.db.forms.find_one(filters)
        form = FormWithAncillaryData(**form)
        if not form:
            return JSONResponse({"error": "not_found"}, status_code=404)

        if constants.FormFeatures.OPEN.value not in form.features:
            form.submission_precheck.problems.append(
                SubmissionProblem(
                    severity=SubmissionPrecheckSeverity.DANGER,
                    message="This form is not open for submissions at the moment.",
                )
            )
            form.submission_precheck.can_submit = False
        elif constants.FormFeatures.UNIQUE_RESPONDER.value in form.features:
            user_id = request.user.payload["id"] if request.user.is_authenticated else None
            if user_id:
                existing_response = await request.state.db.responses.find_one({
                    "form_id": form_id,
                    "user.id": user_id,
                })
                if existing_response:
                    form.submission_precheck.problems.append(
                        SubmissionProblem(
                            severity=SubmissionPrecheckSeverity.DANGER,
                            message="You have already submitted a response to this form.",
                        )
                    )
                    form.submission_precheck.can_submit = False
            else:
                form.submission_precheck.problems.append(
                    SubmissionProblem(
                        severity=SubmissionPrecheckSeverity.SECONDARY,
                        message="You must login at the bottom of the page before submitting this form.",
                    )
                )

        return JSONResponse(form.dict(admin=admin))

    @requires(["authenticated"])
    @api.validate(
        resp=Response(
            HTTP_200=OkayResponse,
            HTTP_400=ErrorMessage,
            HTTP_404=ErrorMessage,
        ),
        tags=["forms"],
    )
    async def patch(self, request: Request) -> JSONResponse:
        """Updates form by ID."""
        try:
            data = await request.json()
        except json.decoder.JSONDecodeError:
            return JSONResponse({"error": "Expected a JSON body."}, 400)

        form_id = request.path_params["form_id"].lower()
        await discord.verify_edit_access(form_id, request)

        if raw_form := await request.state.db.forms.find_one({"_id": form_id}):
            if "_id" in data or "id" in data:
                if (data.get("id") or data.get("_id")) != form_id:
                    return JSONResponse({"error": "locked_field"}, status_code=400)

            # Build Data Merger
            merge_strategy = [
                (dict, ["merge"]),
            ]
            merger = deepmerge.Merger(merge_strategy, ["override"], ["override"])

            # Merge Form Data
            updated_form = merger.merge(raw_form, data)

            try:
                form = Form(**updated_form)
            except ValidationError as e:
                return JSONResponse(e.errors(), status_code=422)

            await request.state.db.forms.replace_one({"_id": form_id}, form.dict())

            return JSONResponse(form.dict())
        return JSONResponse({"error": "not_found"}, status_code=404)

    @requires(["authenticated", "admin"])
    @api.validate(
        resp=Response(HTTP_200=OkayResponse, HTTP_401=ErrorMessage, HTTP_404=ErrorMessage),
        tags=["forms"],
    )
    async def delete(self, request: Request) -> JSONResponse:
        """Deletes form by ID."""
        form_id = request.path_params["form_id"].lower()
        await discord.verify_edit_access(form_id, request)

        await request.state.db.forms.delete_one({"_id": form_id})
        await request.state.db.responses.delete_many({"form_id": form_id})

        return JSONResponse({"status": "ok"})