prune_captcha 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
File without changes
captcha_prune/asgi.py ADDED
@@ -0,0 +1,16 @@
1
+ """
2
+ ASGI config for captcha_prune project.
3
+
4
+ It exposes the ASGI callable as a module-level variable named ``application``.
5
+
6
+ For more information on this file, see
7
+ https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/
8
+ """
9
+
10
+ import os
11
+
12
+ from django.core.asgi import get_asgi_application
13
+
14
+ os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'captcha_prune.settings')
15
+
16
+ application = get_asgi_application()
@@ -0,0 +1,29 @@
1
+ # Generated by Django 5.2 on 2025-04-28 10:19
2
+
3
+ import uuid
4
+ from django.db import migrations, models
5
+
6
+
7
+ class Migration(migrations.Migration):
8
+
9
+ initial = True
10
+
11
+ dependencies = [
12
+ ]
13
+
14
+ operations = [
15
+ migrations.CreateModel(
16
+ name='Captcha',
17
+ fields=[
18
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
19
+ ('created_at', models.DateTimeField(auto_now_add=True)),
20
+ ('updated_at', models.DateTimeField(auto_now=True)),
21
+ ('uuid', models.UUIDField(default=uuid.uuid4, unique=True)),
22
+ ('pos_x', models.IntegerField()),
23
+ ('pos_y', models.IntegerField()),
24
+ ],
25
+ options={
26
+ 'abstract': False,
27
+ },
28
+ ),
29
+ ]
@@ -0,0 +1,73 @@
1
+ # Generated by Django 5.2 on 2025-04-29 07:55
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+ dependencies = [
8
+ ("captcha_prune", "0001_initial"),
9
+ ]
10
+
11
+ operations = [
12
+ migrations.RemoveField(
13
+ model_name="captcha",
14
+ name="created_at",
15
+ ),
16
+ migrations.RemoveField(
17
+ model_name="captcha",
18
+ name="pos_x",
19
+ ),
20
+ migrations.RemoveField(
21
+ model_name="captcha",
22
+ name="pos_y",
23
+ ),
24
+ migrations.RemoveField(
25
+ model_name="captcha",
26
+ name="updated_at",
27
+ ),
28
+ migrations.AddField(
29
+ model_name="captcha",
30
+ name="height",
31
+ field=models.IntegerField(default=200),
32
+ ),
33
+ migrations.AddField(
34
+ model_name="captcha",
35
+ name="piece_height",
36
+ field=models.IntegerField(default=50),
37
+ ),
38
+ migrations.AddField(
39
+ model_name="captcha",
40
+ name="piece_pos_x",
41
+ field=models.IntegerField(blank=True, null=True),
42
+ ),
43
+ migrations.AddField(
44
+ model_name="captcha",
45
+ name="piece_pos_y",
46
+ field=models.IntegerField(blank=True, null=True),
47
+ ),
48
+ migrations.AddField(
49
+ model_name="captcha",
50
+ name="piece_width",
51
+ field=models.IntegerField(default=80),
52
+ ),
53
+ migrations.AddField(
54
+ model_name="captcha",
55
+ name="pos_x_solution",
56
+ field=models.IntegerField(blank=True, null=True),
57
+ ),
58
+ migrations.AddField(
59
+ model_name="captcha",
60
+ name="pos_y_solution",
61
+ field=models.IntegerField(blank=True, null=True),
62
+ ),
63
+ migrations.AddField(
64
+ model_name="captcha",
65
+ name="precision",
66
+ field=models.IntegerField(default=2),
67
+ ),
68
+ migrations.AddField(
69
+ model_name="captcha",
70
+ name="width",
71
+ field=models.IntegerField(default=350),
72
+ ),
73
+ ]
File without changes
@@ -0,0 +1,32 @@
1
+ import random
2
+ import uuid
3
+
4
+ from django.db import models
5
+
6
+
7
+ class Captcha(models.Model):
8
+ uuid = models.UUIDField(default=uuid.uuid4, unique=True)
9
+ width = models.IntegerField(default=350)
10
+ height = models.IntegerField(default=200)
11
+ pos_x_solution = models.IntegerField(null=True, blank=True)
12
+ pos_y_solution = models.IntegerField(null=True, blank=True)
13
+ piece_pos_x = models.IntegerField(null=True, blank=True)
14
+ piece_pos_y = models.IntegerField(null=True, blank=True)
15
+ piece_width = models.IntegerField(default=80)
16
+ piece_height = models.IntegerField(default=50)
17
+ precision = models.IntegerField(default=2)
18
+
19
+ def save(self, *args, **kwargs):
20
+ if self.pos_x_solution is None:
21
+ self.pos_x_solution = random.randint(0, self.width - self.piece_width)
22
+
23
+ if self.pos_y_solution is None:
24
+ self.pos_y_solution = random.randint(0, self.height - self.piece_height)
25
+
26
+ if self.piece_pos_x is None:
27
+ self.piece_pos_x = random.randint(0, self.width - self.piece_width)
28
+
29
+ if self.piece_pos_y is None:
30
+ self.piece_pos_y = random.randint(0, self.height - self.piece_height)
31
+
32
+ super().save(*args, **kwargs)
@@ -0,0 +1,6 @@
1
+ from pydantic import BaseModel
2
+
3
+
4
+ class PuzzleAnswerPayload(BaseModel):
5
+ pos_x_answer: int
6
+ pos_y_answer: int
@@ -0,0 +1,130 @@
1
+ from pathlib import Path
2
+
3
+ from pydantic_settings import BaseSettings, SettingsConfigDict
4
+
5
+
6
+ class EnvSettings(BaseSettings):
7
+ model_config = SettingsConfigDict(env_file=".env", extra="allow")
8
+
9
+ postgres_db: str
10
+ postgres_user: str
11
+ postgres_password: str
12
+ postgres_host: str
13
+ postgres_port: str
14
+
15
+
16
+ env_settings = EnvSettings()
17
+
18
+ # Build paths inside the project like this: BASE_DIR / 'subdir'.
19
+ BASE_DIR = Path(__file__).resolve().parent.parent
20
+
21
+
22
+ # Quick-start development settings - unsuitable for production
23
+ # See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
24
+
25
+ # SECURITY WARNING: keep the secret key used in production secret!
26
+ SECRET_KEY = "django-insecure-dj-w%-kno*r5w=e_&l1sl3)9kl%m25(3ikoc=z_%p5-0d5472%"
27
+
28
+ # SECURITY WARNING: don't run with debug turned on in production!
29
+ DEBUG = True
30
+
31
+ ALLOWED_HOSTS = []
32
+
33
+
34
+ # Application definition
35
+
36
+ INSTALLED_APPS = [
37
+ "django.contrib.admin",
38
+ "django.contrib.auth",
39
+ "django.contrib.contenttypes",
40
+ "django.contrib.sessions",
41
+ "django.contrib.messages",
42
+ "django.contrib.staticfiles",
43
+ "captcha_prune",
44
+ ]
45
+
46
+ MIDDLEWARE = [
47
+ "django.middleware.security.SecurityMiddleware",
48
+ "django.contrib.sessions.middleware.SessionMiddleware",
49
+ "django.middleware.common.CommonMiddleware",
50
+ "django.middleware.csrf.CsrfViewMiddleware",
51
+ "django.contrib.auth.middleware.AuthenticationMiddleware",
52
+ "django.contrib.messages.middleware.MessageMiddleware",
53
+ "django.middleware.clickjacking.XFrameOptionsMiddleware",
54
+ ]
55
+
56
+ ROOT_URLCONF = "captcha_prune.urls"
57
+
58
+ TEMPLATES = [
59
+ {
60
+ "BACKEND": "django.template.backends.django.DjangoTemplates",
61
+ "DIRS": [],
62
+ "APP_DIRS": True,
63
+ "OPTIONS": {
64
+ "context_processors": [
65
+ "django.template.context_processors.request",
66
+ "django.contrib.auth.context_processors.auth",
67
+ "django.contrib.messages.context_processors.messages",
68
+ ],
69
+ },
70
+ },
71
+ ]
72
+
73
+ WSGI_APPLICATION = "captcha_prune.wsgi.application"
74
+
75
+
76
+ # Database
77
+ # https://docs.djangoproject.com/en/5.2/ref/settings/#databases
78
+
79
+ DATABASES = {
80
+ "default": {
81
+ "ENGINE": "django.db.backends.postgresql",
82
+ "NAME": env_settings.postgres_db,
83
+ "USER": env_settings.postgres_user,
84
+ "PASSWORD": env_settings.postgres_password,
85
+ "HOST": env_settings.postgres_host,
86
+ "PORT": env_settings.postgres_port,
87
+ }
88
+ }
89
+
90
+
91
+ # Password validation
92
+ # https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators
93
+
94
+ AUTH_PASSWORD_VALIDATORS = [
95
+ {
96
+ "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
97
+ },
98
+ {
99
+ "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
100
+ },
101
+ {
102
+ "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
103
+ },
104
+ {
105
+ "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
106
+ },
107
+ ]
108
+
109
+
110
+ # Internationalization
111
+ # https://docs.djangoproject.com/en/5.2/topics/i18n/
112
+
113
+ LANGUAGE_CODE = "en-us"
114
+
115
+ TIME_ZONE = "UTC"
116
+
117
+ USE_I18N = True
118
+
119
+ USE_TZ = True
120
+
121
+
122
+ # Static files (CSS, JavaScript, Images)
123
+ # https://docs.djangoproject.com/en/5.2/howto/static-files/
124
+
125
+ STATIC_URL = "static/"
126
+
127
+ # Default primary key field type
128
+ # https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
129
+
130
+ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
captcha_prune/urls.py ADDED
@@ -0,0 +1,10 @@
1
+ from django.urls import path
2
+
3
+ from captcha_prune.views import create_captcha_view, verify_captcha_view
4
+
5
+ app_name = "captcha"
6
+
7
+ urlpatterns = [
8
+ path("", create_captcha_view, name="create-captcha"),
9
+ path("<str:uuid>/", verify_captcha_view, name="verify-captcha"),
10
+ ]
captcha_prune/views.py ADDED
@@ -0,0 +1,45 @@
1
+ from django.http import HttpRequest, HttpResponse, JsonResponse
2
+ from django.shortcuts import get_object_or_404
3
+ from django.views.decorators.csrf import csrf_exempt
4
+ from django.views.decorators.http import require_GET, require_POST
5
+
6
+ from captcha_prune.models import Captcha
7
+ from captcha_prune.payloads import PuzzleAnswerPayload
8
+ from commons.decorators import use_payload
9
+
10
+
11
+ @require_POST
12
+ @csrf_exempt
13
+ def create_captcha_view(request: HttpRequest) -> HttpResponse:
14
+ captcha = Captcha.objects.create()
15
+ return JsonResponse(
16
+ status=201,
17
+ data={
18
+ "uuid": str(captcha.uuid),
19
+ "width": captcha.width,
20
+ "height": captcha.height,
21
+ "piece_width": captcha.piece_width,
22
+ "piece_height": captcha.piece_height,
23
+ "pos_x_solution": captcha.pos_x_solution,
24
+ "pos_y_solution": captcha.pos_y_solution,
25
+ "piece_pos_x": captcha.piece_pos_x,
26
+ "piece_pos_y": captcha.piece_pos_y,
27
+ },
28
+ )
29
+
30
+
31
+ @require_GET
32
+ @use_payload(PuzzleAnswerPayload)
33
+ def verify_captcha_view(
34
+ request: HttpRequest, uuid: str, payload: PuzzleAnswerPayload
35
+ ) -> HttpResponse:
36
+ captcha = get_object_or_404(Captcha, uuid=uuid)
37
+ pos_x_answer = payload.pos_x_answer
38
+ pos_y_answer = payload.pos_y_answer
39
+ if (
40
+ abs(captcha.pos_x_solution - pos_x_answer) <= captcha.precision
41
+ and abs(captcha.pos_y_solution - pos_y_answer) <= captcha.precision
42
+ ):
43
+ return HttpResponse(status=304)
44
+ else:
45
+ return HttpResponse(status=401)
captcha_prune/wsgi.py ADDED
@@ -0,0 +1,16 @@
1
+ """
2
+ WSGI config for captcha_prune project.
3
+
4
+ It exposes the WSGI callable as a module-level variable named ``application``.
5
+
6
+ For more information on this file, see
7
+ https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/
8
+ """
9
+
10
+ import os
11
+
12
+ from django.core.wsgi import get_wsgi_application
13
+
14
+ os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'captcha_prune.settings')
15
+
16
+ application = get_wsgi_application()
commons/base_model.py ADDED
@@ -0,0 +1,9 @@
1
+ from django.db import models
2
+
3
+
4
+ class BaseModel(models.Model):
5
+ created_at = models.DateTimeField(auto_now_add=True)
6
+ updated_at = models.DateTimeField(auto_now=True)
7
+
8
+ class Meta:
9
+ abstract = True
commons/decorators.py ADDED
@@ -0,0 +1,41 @@
1
+ import functools
2
+ from typing import get_origin
3
+
4
+ from django.http import HttpResponse
5
+ from pydantic import ValidationError
6
+
7
+
8
+ def use_payload(payload_class):
9
+ def decorator(func):
10
+ @functools.wraps(func)
11
+ def wrapper(request, *args, **kwargs):
12
+ data = {}
13
+
14
+ if request.method == "POST":
15
+ body = request.POST
16
+ elif request.method == "GET":
17
+ body = request.GET
18
+ else:
19
+ raise NotImplementedError
20
+
21
+ for field_name, field in payload_class.model_fields.items():
22
+ url_name = field.alias if field.alias else field_name
23
+ is_list_field = get_origin(field.annotation) is list
24
+ url_value = (
25
+ body.getlist(url_name) if is_list_field else body.get(url_name)
26
+ )
27
+ if url_value is not None:
28
+ data[url_name] = url_value
29
+
30
+ try:
31
+ payload = payload_class.model_validate(data)
32
+ except ValidationError as e:
33
+ return HttpResponse(
34
+ e.json(), headers={"content_type": "application/json"}, status=400
35
+ )
36
+
37
+ return func(request, *args, payload=payload, **kwargs)
38
+
39
+ return wrapper
40
+
41
+ return decorator
@@ -0,0 +1,91 @@
1
+ Metadata-Version: 2.4
2
+ Name: prune_captcha
3
+ Version: 1.0.0
4
+ Summary: A tool to protect formulaire from spam.
5
+ Author-email: Arnout <bastien@prune.sh>
6
+ Project-URL: Made_by, https://prune.sh/
7
+ Keywords: captcha,django,code-quality
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: <4.0,>=3.12
12
+ Description-Content-Type: text/markdown
13
+ Requires-Dist: django>=5.2
14
+ Requires-Dist: psycopg2-binary>=2.9.10
15
+ Requires-Dist: pydantic>=2.11.4
16
+ Requires-Dist: pydantic-settings>=2.9.1
17
+
18
+ # Prune's Captcha
19
+
20
+ ## What is it for?
21
+
22
+ Captcha helps prevent robots from spamming using your forms.
23
+
24
+ ## Prerequisites
25
+
26
+ - To be installed on a Prune Django project that uses poetry or UV
27
+
28
+ ## UV project
29
+
30
+ ### Installation
31
+
32
+ Run the following command in the console:
33
+
34
+ ```bash
35
+ uv add captcha_prune
36
+ ```
37
+
38
+ ### Running the captcha
39
+
40
+ To run the package, simply enter in the console:
41
+
42
+ ```bash
43
+ captcha_prune
44
+ ```
45
+
46
+ ### Updating the captcha
47
+
48
+ Don't hesitate to regularly run `uv sync --upgrade`, as the captcha evolves with time and our practices!
49
+
50
+ ## Poetry project
51
+
52
+ ### Installation
53
+
54
+ Run the following command:
55
+
56
+ ```bash
57
+ poetry add prune_captcha
58
+ ```
59
+
60
+ ### Running the captcha
61
+
62
+ ```bash
63
+ poetry run prune_captcha
64
+ ```
65
+
66
+ ### Updating the captcha
67
+
68
+ Don't hesitate to regularly run `poetry update`, as the captcha evolves with time and our practices!
69
+
70
+ ## Captcha Integration
71
+
72
+ Once the project is launched, the application's URLs need to be linked to the form.
73
+
74
+ - When making a **GET** request to the form page, the API must be called via the **creation endpoint**. The response will contain the necessary information to display the captcha.
75
+ - When submitting the form via **POST**, the API must be called via the **verification endpoint**. The response will indicate whether the captcha is valid or not.
76
+
77
+ ## Captcha Display
78
+
79
+ To display the captcha correctly, use the data received in the response from the creation request. This data includes:
80
+
81
+ - the captcha's width and height,
82
+ - the piece's width and height,
83
+ - the current position of the piece,
84
+ - the target position (where the piece should be placed),
85
+ - the expected precision (captcha difficulty level).
86
+
87
+ # Available Versions
88
+
89
+ | Version | Date | Notes |
90
+ | ------- | ---------- | -------------------------------------- |
91
+ | 1.0.0 | 2025-03-14 | First version of the `captcha` package |
@@ -0,0 +1,21 @@
1
+ captcha_prune/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ captcha_prune/asgi.py,sha256=wi2rRLFltXOZyve8mAB_E8udaFyONOf5N42WrWIQX8M,403
3
+ captcha_prune/models.py,sha256=3IwEWBl5ykZEivE5FGU0jUZKZGVkhvCuMh1HFehjYFU,1201
4
+ captcha_prune/payloads.py,sha256=PwR48xKg9YEgGgoJmGe7ywwFPCqM4gumomyFunn-Ems,115
5
+ captcha_prune/settings.py,sha256=YEZHEjYYYK-cH3gSLIR3a7g4Bypnuf_5eMTTcm719CE,3395
6
+ captcha_prune/urls.py,sha256=DeCJTjJA0krW27KoHafvh3GBRr7fJ80E2lVHEWYSsW4,271
7
+ captcha_prune/views.py,sha256=BzjQfXLwYx2YSWODrnGpokqU8XeC-9QORivzWKxYofY,1539
8
+ captcha_prune/wsgi.py,sha256=fukA_iiCT4FFzcJ0QX2Zr_HNddqdq-ln3ien2UWcCTA,403
9
+ captcha_prune/migrations/0001_initial.py,sha256=QMTaeSIxPociSrV4r89zfsbxMwMu4qpL16LT3yPgcaw,831
10
+ captcha_prune/migrations/0002_remove_captcha_created_at_remove_captcha_pos_x_and_more.py,sha256=xyyE_G3t8kJqFvKuNWFRTyTyyD1kzvfeSgbkTQ9DO8A,2099
11
+ captcha_prune/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
+ commons/base_model.py,sha256=q1Q7lgtvN_t6ujhEiSsNNXb_ay0qY6FbfK0Zm2Hdgp0,213
13
+ commons/decorators.py,sha256=nU_3SyxENcWDdxo03VyNYEvoevplC6ig7BFogY8apNs,1306
14
+ tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
+ tests/captcha_prune/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
16
+ tests/captcha_prune/test_views.py,sha256=h-Mrdiprh71s7kIJPYAt6xv4G_S7vp51WCpAzy4CZ6U,2364
17
+ prune_captcha-1.0.0.dist-info/METADATA,sha256=bUB-rBL654MCCPo0kmTGlwmCHtfkA-DDr56hPjpkALI,2447
18
+ prune_captcha-1.0.0.dist-info/WHEEL,sha256=ooBFpIzZCPdw3uqIQsOo4qqbA4ZRPxHnOH7peeONza0,91
19
+ prune_captcha-1.0.0.dist-info/entry_points.txt,sha256=_7zxh1tub_wWQu9oo5vcYIJAEQMPmTj0ekh0oXJ_8FM,52
20
+ prune_captcha-1.0.0.dist-info/top_level.txt,sha256=EB4h0WF_YGF7kAWB0XtqmuloOhkL5pC71CzgEVYam7w,28
21
+ prune_captcha-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ prune_captcha = start_server:main
@@ -0,0 +1,3 @@
1
+ captcha_prune
2
+ commons
3
+ tests
tests/__init__.py ADDED
File without changes
File without changes
@@ -0,0 +1,67 @@
1
+ import uuid
2
+
3
+ from django.test import TestCase
4
+ from django.urls import reverse
5
+
6
+ from captcha_prune.models import Captcha
7
+
8
+
9
+ class CreateCaptchaViewTestCase(TestCase):
10
+ def test_create_captcha_with_GET_method(self):
11
+ response = self.client.get(reverse("create-captcha"))
12
+ self.assertEqual(response.status_code, 405)
13
+
14
+ def test_create_captcha_with_POST_method(self):
15
+ response = self.client.post(reverse("create-captcha"))
16
+ self.assertEqual(response.status_code, 201)
17
+
18
+ data = response.json()
19
+ captcha = Captcha.objects.get(uuid=data["uuid"])
20
+ self.assertIsNotNone(captcha)
21
+
22
+
23
+ class VerifyCaptchaViewTestCase(TestCase):
24
+ def setUp(self):
25
+ self.captcha = Captcha.objects.create()
26
+ self.pos_x_solution = self.captcha.pos_x_solution
27
+ self.pos_y_solution = self.captcha.pos_y_solution
28
+
29
+ def test_verify_captcha_with_POST_method(self):
30
+ response = self.client.post(
31
+ reverse("verify-captcha", kwargs={"uuid": str(uuid.uuid4())}),
32
+ data={
33
+ "pos_x_answer": self.pos_x_solution,
34
+ "pos_y_answer": self.pos_y_solution,
35
+ },
36
+ )
37
+ self.assertEqual(response.status_code, 405)
38
+
39
+ def test_verify_captcha_with_bad_uuid(self):
40
+ response = self.client.get(
41
+ reverse("verify-captcha", kwargs={"uuid": str(uuid.uuid4())}),
42
+ data={
43
+ "pos_x_answer": self.pos_x_solution,
44
+ "pos_y_answer": self.pos_y_solution,
45
+ },
46
+ )
47
+ self.assertEqual(response.status_code, 404)
48
+
49
+ def test_verify_captcha_with_good_uuid_but_bad_answer(self):
50
+ response = self.client.get(
51
+ reverse("verify-captcha", kwargs={"uuid": str(self.captcha.uuid)}),
52
+ data={
53
+ "pos_x_answer": self.pos_x_solution - 20,
54
+ "pos_y_answer": self.pos_y_solution - 20,
55
+ },
56
+ )
57
+ self.assertEqual(response.status_code, 401)
58
+
59
+ def test_verify_captcha_with_good_uuid_and_good_answer(self):
60
+ response = self.client.get(
61
+ reverse("verify-captcha", kwargs={"uuid": str(self.captcha.uuid)}),
62
+ data={
63
+ "pos_x_answer": self.pos_x_solution,
64
+ "pos_y_answer": self.pos_y_solution,
65
+ },
66
+ )
67
+ self.assertEqual(response.status_code, 304)