diff --git a/openapi/stregsystem.yaml b/openapi/stregsystem.yaml index ba6a660b..a9034612 100644 --- a/openapi/stregsystem.yaml +++ b/openapi/stregsystem.yaml @@ -7,7 +7,7 @@ info: Existing client software utilizing the API include Stregsystem-CLI (STS) and Fappen (F-Club Web App). Disclaimer - The implementation is not generated using this specification, therefore they can get out of sync if changes are made directly to the codebase without updating the OpenAPI specification file accordingly. - version: "1.1" + version: "1.1.1" externalDocs: description: Find out more about Stregsystemet at GitHub. url: https://github.com/f-klubben/stregsystemet/ @@ -21,6 +21,8 @@ tags: description: Related to the products. - name: Sale description: Related to performing a sale. + - name: Signup + description: Related to registration of new members. paths: /api/member: get: @@ -159,6 +161,38 @@ paths: $ref: '#/components/responses/SaleSuccess' '400': $ref: '#/components/responses/Member_RoomIdParameter_BadResponse' + /api/signup: + post: + tags: + - Signup + summary: Posts a signup + description: Performs a signup using member info. + operationId: api_signup + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/signup_input' + responses: + '200': + $ref: '#/components/responses/SignupSuccess' + '400': + $ref: '#/components/responses/Signup_BadResponse' + /api/signup/status: + get: + tags: + - Signup + summary: Gets status regarding a signup + description: Retrieves the signup status for a specific member by username. + operationId: api_signup_status + parameters: + - $ref: '#/components/parameters/username_param' + responses: + '200': + $ref: '#/components/responses/SignupStatus' + '400': + $ref: '#/components/responses/MemberUsernameParameter_BadResponse' components: examples: MemberNotFoundExample: @@ -182,7 +216,20 @@ components: MissingMemberUsernameExample: summary: No username given value: "Parameter missing: username" + UsernameTakenExample: + summary: Member with that username already exist + value: "Username taken" + MissingOrInvalidParameterExample: + summary: A parameter is invalid or missing + value: "Parameter invalid: " parameters: + signup_id_param: + name: signup_id + in: query + description: Signup ID of the signup to retrieve. + required: true + schema: + $ref: '#/components/schemas/signup_id' member_id_param: name: member_id in: query @@ -239,6 +286,12 @@ components: missingMemberUsernameMessage: type: string example: "Parameter missing: username" + usernameTakenMessage: + type: string + example: "Username taken" + invalidParameterMessage: + type: string + example: "Parameter invalid: " balance: type: integer example: 20000 @@ -257,6 +310,35 @@ components: room_id: type: integer example: 10 + email: + type: string + format: email + example: kresten@example.org + firstname: + type: string + example: Kresten + lastname: + type: string + example: Laust + education: + type: string + description: Acknowledged shortening, e.g. sw/ixd/dad/dat + example: sw + gender: + type: string + enum: + - U + - M + - F + example: M + approval_status: + type: string + description: U = Unreviewed, A = Approved, I = Ignored, R = Rejected. + enum: + - U + - A + - I + - R timestamp: type: string format: date-time @@ -274,6 +356,9 @@ components: type: integer example: 1800 stregoere_balance: + type: integer + example: 15000 + stregoere_due: type: integer example: 20000 stregkroner_balance: @@ -281,6 +366,14 @@ components: type: number format: float example: 182.00 + signup_id: + type: integer + example: 5 + named_products_example: + type: object + properties: + beer: + $ref: '#/components/schemas/product_id' sale_input: type: object properties: @@ -290,6 +383,21 @@ components: $ref: '#/components/schemas/buystring' room: $ref: '#/components/schemas/room_id' + signup_input: + type: object + properties: + education: + $ref: '#/components/schemas/education' + username: + $ref: '#/components/schemas/username' + email: + $ref: '#/components/schemas/email' + firstname: + $ref: '#/components/schemas/firstname' + lastname: + $ref: '#/components/schemas/lastname' + gender: + $ref: '#/components/schemas/gender' active_product: type: object properties: @@ -359,6 +467,13 @@ components: member_has_low_balance: type: boolean example: false + signup_values_result_example: + type: object + properties: + due: + $ref: '#/components/schemas/stregoere_due' + username: + $ref: '#/components/schemas/username' sale_values_result_example: type: object properties: @@ -472,6 +587,17 @@ components: properties: sales: $ref: '#/components/schemas/sales' + SignupStatus: + description: Signup information found. + content: + application/json: + schema: + type: object + properties: + due: + $ref: '#/components/schemas/stregoere_due' + status: + $ref: '#/components/schemas/approval_status' NamedProducts: description: Dictionary of all named_product names. content: @@ -511,6 +637,21 @@ components: example: "OK" values: $ref: '#/components/schemas/sale_values_result_example' + SignupSuccess: + description: An object containing info regarding the signup. + content: + application/json: + schema: + type: object + properties: + status: + type: integer + example: 200 + msg: + type: string + example: "OK" + values: + $ref: '#/components/schemas/signup_values_result_example' QRCodeGenerated: description: QR code with link to open MobilePay with the provided information. content: @@ -594,3 +735,16 @@ components: $ref: '#/components/examples/InvalidRoomIdExample' missingRoomId: $ref: '#/components/examples/MissingRoomIdExample' + Signup_BadResponse: + description: Username is taken, missing parameter, or invalid parameter. + content: + text/html: + schema: + oneOf: + - $ref: '#/components/schemas/usernameTakenMessage' + - $ref: '#/components/schemas/invalidParameterMessage' + examples: + usernameTaken: + $ref: '#/components/examples/UsernameTakenExample' + invalidParameter: + $ref: '#/components/examples/MissingOrInvalidParameterExample' diff --git a/stregsystem/tests.py b/stregsystem/tests.py index bf953762..c86c07ae 100644 --- a/stregsystem/tests.py +++ b/stregsystem/tests.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- import datetime +import json from collections import Counter from copy import deepcopy from unittest import mock @@ -2272,3 +2273,75 @@ def test_welcome_mail_paid_approved(self, mock_mail_method: MagicMock): signup_request.approve() mock_mail_method.assert_called_once() + + +class ApiTests(TestCase): + """ + A lot of the API testing is done separately using Dredd and OpenAPI. + These are edge-cases which can't be expressed in OpenAPI. + """ + + def setUp(self): + member = Member.objects.create(username="martin_p", email="test@example.com", signup_due_paid=False) + member.save() + + def test_signup_duplicate_username(self): + response = self.client.post( + reverse('api_signup'), + json.dumps( + { + 'education': "sw", + 'username': "martin_p", # Note: Duplicate username + 'firstname': "Martin", + 'lastname': "P.", + 'email': "test2@example.com", + 'gender': "M", + } + ), + content_type="application/json", + ) + + self.assertNotEquals(response.status_code, 200) + + def test_signup_partial_form_no_name(self): + response = self.client.post( + reverse('api_signup'), + json.dumps({'education': "sw", 'username': "martin_p2", 'email': "test2@example.com", 'gender': "M"}), + content_type="application/json", + ) + + self.assertNotEquals(response.status_code, 200) + + def test_signup_partial_form_no_username(self): + response = self.client.post( + reverse('api_signup'), + json.dumps( + { + 'education': "sw", + 'firstname': "Martin", + 'lastname': "P.", + 'email': "test2@example.com", + 'gender': "M", + } + ), + content_type="application/json", + ) + + self.assertNotEquals(response.status_code, 200) + + def test_signup_partial_form_no_education(self): + response = self.client.post( + reverse('api_signup'), + json.dumps( + { + 'username': "martin_p2", + 'firstname': "Martin", + 'lastname': "P.", + 'email': "test2@example.com", + 'gender': "M", + } + ), + content_type="application/json", + ) + + self.assertNotEquals(response.status_code, 200) diff --git a/stregsystem/urls.py b/stregsystem/urls.py index 9fbbe9bb..e6e576db 100644 --- a/stregsystem/urls.py +++ b/stregsystem/urls.py @@ -43,4 +43,6 @@ re_path(r'^api/products/active_products$', views.get_active_items, name="api_active_products"), re_path(r'^api/products/category_mappings$', views.get_product_category_mappings, name="api_product_mappings"), re_path(r'^api/sale$', views.api_sale, name="api_sale"), + re_path(r'^api/signup$', views.post_signup, name="api_signup"), + re_path(r'^api/signup/status', views.get_signup_status, name="api_signup_status"), ] diff --git a/stregsystem/views.py b/stregsystem/views.py index dcc14c13..05b6bf84 100644 --- a/stregsystem/views.py +++ b/stregsystem/views.py @@ -16,6 +16,7 @@ from django.contrib.admin.views.decorators import staff_member_required from django.contrib.auth.decorators import permission_required from django.core import management +from django.core.exceptions import ValidationError from django.db.models import Q, Count, Sum from django.forms import modelformset_factory from django.http import HttpResponsePermanentRedirect, HttpResponseBadRequest, JsonResponse @@ -594,6 +595,28 @@ def get_payment_qr(request): return qr_code(mobilepay_launch_uri(username, amount)) +def perform_signup(validated_form: SignupForm) -> PendingSignup: + if not validated_form.is_valid(): + raise ValidationError("The provided form contains errors: %s" % validated_form.errors) + + if Member.objects.filter(username=validated_form.cleaned_data.get('username')).all().count() > 0: + raise ValidationError("Username already taken") + + member = Member.objects.create( + username=validated_form.cleaned_data.get('username'), + firstname=validated_form.cleaned_data.get('firstname'), + lastname=validated_form.cleaned_data.get('lastname'), + email=validated_form.cleaned_data.get('email'), + notes=validated_form.cleaned_data.get('notes'), + gender=validated_form.cleaned_data.get('gender'), + signup_due_paid=False, + ) + signup_request = PendingSignup(member=member, due=200 * 100) + signup_request.save() + + return signup_request + + def signup(request): is_post = request.method == "POST" form = SignupForm(request.POST) if is_post else SignupForm() @@ -603,19 +626,9 @@ def signup(request): form.add_error("username", "Brugernavn allerede i brug") return render(request, "stregsystem/signup.html", locals()) - member = Member.objects.create( - username=form.cleaned_data.get('username'), - firstname=form.cleaned_data.get('firstname'), - lastname=form.cleaned_data.get('lastname'), - email=form.cleaned_data.get('email'), - notes=form.cleaned_data.get('notes'), - gender=form.cleaned_data.get('gender'), - signup_due_paid=False, - ) - signup_request = PendingSignup(member=member, due=200 * 100) - signup_request.save() + pending_signup = perform_signup(form) - return redirect('signup_status', signup_id=signup_request.id) + return redirect('signup_status', signup_id=pending_signup.id) return render(request, "stregsystem/signup.html", locals()) @@ -763,6 +776,58 @@ def get_named_products(request): return JsonResponse(items_dict, json_dumps_params={'ensure_ascii': False}) +def get_signup_status(request): + username = request.GET.get('username') or None + if username is None: + return HttpResponseBadRequest("Parameter missing: username") + + try: + member = Member.objects.get(username=username) + except Member.DoesNotExist: + return HttpResponseBadRequest("Member not found") + + try: + pending_signup = PendingSignup.objects.get(member=member) + return JsonResponse({'due': pending_signup.due, 'status': pending_signup.status}) + except PendingSignup.DoesNotExist: + # Member exists but no signup object does, assume it was approved. + return JsonResponse({'due': 0, 'status': ApprovalModel.APPROVED}) + + +@csrf_exempt +def post_signup(request): + if request.method != "POST": + return HttpResponseBadRequest() + else: + try: + data = json.loads(request.body) + except json.JSONDecodeError: + return HttpResponseBadRequest("Invalid JSON payload.") + + signup_form = SignupForm(data) + + if not signup_form.is_valid(): + return HttpResponseBadRequest(f"Parameter invalid: {', '.join(signup_form.errors.keys())}") + + try: + pending_signup = perform_signup(signup_form) + except ValidationError as err: + return HttpResponseBadRequest(err.message) + + msg, status, ret_obj = ( + "OK", + 200, + { + 'due': pending_signup.due, + 'username': pending_signup.member.username, + }, + ) + + return JsonResponse( + {'status': status, 'msg': msg, 'values': ret_obj}, json_dumps_params={'ensure_ascii': False} + ) + + @csrf_exempt def api_sale(request): if request.method != "POST":