Skip to content
This repository was archived by the owner on Apr 29, 2022. It is now read-only.

Commit 4ce2dcc

Browse files
authored
Merge branch 'ep2021' into issue-823-improve-textcha-for-registration
2 parents 5ea2129 + 4570fc9 commit 4ce2dcc

File tree

8 files changed

+393
-16
lines changed

8 files changed

+393
-16
lines changed
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Generated by Django 2.2.19 on 2021-05-17 07:32
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('assopy', '0015_remove_refund_models'),
10+
]
11+
12+
operations = [
13+
migrations.AlterField(
14+
model_name='vat',
15+
name='fares',
16+
field=models.ManyToManyField(blank=True, through='assopy.VatFare', to='conference.Fare'),
17+
),
18+
]

assopy/models.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -511,7 +511,7 @@ def create(self, user, payment, items, billing_notes='', coupons=None, country=N
511511
class Vat(models.Model):
512512
fares = models.ManyToManyField(Fare,
513513
through='VatFare',
514-
null=True, blank=True)
514+
blank=True)
515515
value = models.DecimalField(max_digits=5, decimal_places=2)
516516
description = models.CharField(null=True, blank=True, max_length=125)
517517
invoice_notice = models.TextField(null=True, blank=True)

conference/api.py

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
"""
2+
Matrix/Synapse custom authentication provider backend.
3+
4+
This allows a Matrix/Synapse installation to use a custom backaned (not part of
5+
this API) to authenticate users against epcon database.
6+
7+
The main (and currently the only) endpoint is
8+
9+
/api/v1/isauth
10+
11+
For more information about developing a custom auth backend for matrix/synapse
12+
please refer to https://github.com/matrix-org/synapse/blob/master/docs/\
13+
password_auth_providers.md
14+
"""
15+
from enum import Enum
16+
import json
17+
from functools import wraps
18+
from django.conf.urls import url as re_path
19+
from django.contrib.auth.hashers import check_password
20+
from django.db.models import Q
21+
from django.http import JsonResponse
22+
from django.views.decorators.csrf import csrf_exempt
23+
from conference.models import (
24+
AttendeeProfile,
25+
Conference,
26+
Speaker,
27+
TalkSpeaker,
28+
Ticket,
29+
)
30+
from pycon.settings import MATRIX_AUTH_API_DEBUG as DEBUG
31+
from pycon.settings import MATRIX_AUTH_API_ALLOWED_IPS as ALLOWED_IPS
32+
33+
34+
# Error Codes
35+
class ApiError(Enum):
36+
WRONG_METHOD = 1
37+
AUTH_ERROR = 2
38+
INPUT_ERROR = 3
39+
UNAUTHORIZED = 4
40+
WRONG_SCHEME = 5
41+
BAD_REQUEST = 6
42+
43+
44+
def _error(error: ApiError, msg: str) -> JsonResponse:
45+
return JsonResponse({
46+
'error': error.value,
47+
'message': f'{error.name}: {msg}'
48+
})
49+
50+
51+
def get_client_ip(request) -> str:
52+
"""
53+
Return the client IP.
54+
55+
This is a best effort way of fetching the client IP which does not protect
56+
against spoofing and hich tries to understand some proxying.
57+
58+
This should NOT be relied upon for serius stuff.
59+
"""
60+
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
61+
if x_forwarded_for:
62+
ip = x_forwarded_for.split(',')[0]
63+
else:
64+
ip = request.META.get('REMOTE_ADDR')
65+
return ip
66+
67+
68+
# Checkers
69+
def request_checker(checker, error_msg):
70+
"""
71+
Generic sanity check decorator on views.
72+
73+
It accepts two parameters:
74+
`checker`: a function that accepts a request and returns True if valid
75+
`error_msg`: what to return as error message if request is invalid
76+
77+
In case of invalid requests, it returns a BAD_REQUEST error.
78+
"""
79+
def decorator(fn):
80+
@wraps(fn)
81+
def wrapper(request, *args, **kwargs):
82+
if not checker(request):
83+
return _error(ApiError.BAD_REQUEST, error_msg)
84+
return fn(request, *args, **kwargs)
85+
return wrapper
86+
return decorator
87+
88+
89+
# Ensure that the view is called via an HTTPS request and return a JSON error
90+
# payload if not. If DEBUG = True, it has no effect.
91+
ensure_https_in_ops = request_checker(
92+
lambda r: DEBUG or r.is_secure(), 'please use HTTPS'
93+
)
94+
95+
# We use this instead of the bult-in decorator to return a JSON error
96+
# payload instead of a simple 405.
97+
ensure_post = request_checker(lambda r: r.method == 'POST', 'please use POST')
98+
99+
ensure_json_content_type = request_checker(
100+
lambda r: r.content_type == 'application/json', 'please send JSON'
101+
)
102+
103+
104+
def restrict_client_ip_to_allowed_list(fn):
105+
@wraps(fn)
106+
def wrapper(request, *args, **kwargs):
107+
# This is really a best effort attempt at detecting the client IP. It
108+
# does NOT handle IP spooding or any similar attack.
109+
best_effort_ip = get_client_ip(request)
110+
if ALLOWED_IPS and best_effort_ip not in ALLOWED_IPS:
111+
return _error(ApiError.UNAUTHORIZED, 'you are not authorized here')
112+
return fn(request, *args, **kwargs)
113+
return wrapper
114+
115+
116+
@csrf_exempt
117+
@ensure_post
118+
@ensure_https_in_ops
119+
@ensure_json_content_type
120+
@restrict_client_ip_to_allowed_list
121+
def isauth(request):
122+
"""
123+
Return whether or not the given email and password (sent via POST) are
124+
valid. If they are indeed valid, return the number and type of tickets
125+
assigned to the user, together with some other user metadata (see below).
126+
127+
Input via POST:
128+
{
129+
"email": str,
130+
"password": str (not encrypted)
131+
}
132+
133+
Output (JSON)
134+
{
135+
"username": str,
136+
"first_name": str,
137+
"last_name": str,
138+
"email": str,
139+
"is_staff": bool,
140+
"is_speaker": bool,
141+
"is_active": bool,
142+
"is_minor": bool,
143+
"tickets": [{"fare_name": str, "fare_code": str}*]
144+
}
145+
146+
Tickets, if any, are returned only for the currently active conference and
147+
only if ASSIGNED to the user identified by `email`.
148+
149+
In case of any error (including but not limited to if either email or
150+
password are incorrect/unknown), return
151+
{
152+
"message": str,
153+
"error": int
154+
}
155+
"""
156+
required_fields = {'email', 'password'}
157+
158+
try:
159+
data = json.loads(request.body)
160+
except json.decoder.JSONDecodeError as ex:
161+
return _error(ApiError.INPUT_ERROR, ex.msg)
162+
163+
if not isinstance(data, dict) or not required_fields.issubset(data.keys()):
164+
return _error(ApiError.INPUT_ERROR,
165+
'please provide credentials in JSON format')
166+
167+
# First, let's find the user/account profile given the email address
168+
try:
169+
profile = AttendeeProfile.objects.get(user__email=data['email'])
170+
except AttendeeProfile.DoesNotExist:
171+
return _error(ApiError.AUTH_ERROR, 'unknown user')
172+
173+
# Is the password OK?
174+
if not check_password(data['password'], profile.user.password):
175+
return _error(ApiError.AUTH_ERROR, 'authentication error')
176+
177+
# Get the tickets **assigned** to the user
178+
conference = Conference.objects.current()
179+
180+
tickets = Ticket.objects.filter(
181+
Q(fare__conference=conference.code)
182+
& Q(frozen=False) # i.e. the ticket was not cancelled
183+
& Q(orderitem__order___complete=True) # i.e. they paid
184+
& Q(user=profile.user) # i.e. assigned to user
185+
)
186+
187+
# A speaker is a user with at least one accepted talk in the current
188+
# conference.
189+
try:
190+
speaker = profile.user.speaker
191+
except Speaker.DoesNotExist:
192+
is_speaker = False
193+
else:
194+
is_speaker = TalkSpeaker.objects.filter(
195+
speaker=speaker,
196+
talk__conference=conference.code,
197+
talk__status='accepted'
198+
).count() > 0
199+
200+
payload = {
201+
"username": profile.user.username,
202+
"first_name": profile.user.first_name,
203+
"last_name": profile.user.last_name,
204+
"email": profile.user.email,
205+
"is_staff": profile.user.is_staff,
206+
"is_speaker": is_speaker,
207+
"is_active": profile.user.is_active,
208+
"is_minor": profile.is_minor,
209+
"tickets": [
210+
{"fare_name": t.fare.name, "fare_code": t.fare.code}
211+
for t in tickets
212+
]
213+
}
214+
215+
# Just a little nice to have thing when debugging: we can send in the POST
216+
# payload, all the fields that we want to override in the answer and they
217+
# will just be passed through regardless of what is in the DB. We just
218+
# remove the password to be on the safe side.
219+
if DEBUG:
220+
data.pop('password')
221+
payload.update(data)
222+
return JsonResponse(payload)
223+
224+
225+
urlpatterns = [
226+
re_path(r"^v1/isauth/$", isauth, name="isauth"),
227+
]

p3/management/commands/create_bulk_coupons.py

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11

2-
""" Create a batch of single use discount coupons from a CSV file.
2+
""" Create/update a batch of discount coupons from a CSV file.
33
44
Parameters: <conference> <csv-file>
55
6-
Creates coupons based on the CSV file contents:
6+
Creates/updates coupons based on the CSV file contents:
77
88
code - coupon code
99
max_usage - max. number of uses
@@ -14,6 +14,9 @@
1414
1515
Use --dry-run to test drive the script.
1616
17+
Existing coupon codes will get updated by the script. Indexing is by
18+
code.
19+
1720
"""
1821
import sys
1922
import csv
@@ -54,9 +57,9 @@ def handle(self, *args, **options):
5457
csv_filename = options['csv']
5558

5659
# Get set of existing coupon codes
57-
all_codes = set(c['code'] for c in Coupon.objects\
58-
.filter(conference=conference.code)\
59-
.values('code'))
60+
all_codes = dict((c.code, c)
61+
for c in Coupon.objects\
62+
.filter(conference=conference.code))
6063

6164
# Valid fares (conference fares only)
6265
all_fares = cmodels.Fare.objects\
@@ -70,23 +73,24 @@ def handle(self, *args, **options):
7073
with csv_file:
7174
reader = csv.DictReader(csv_file)
7275
for row in reader:
76+
#print ('Row %r' % row)
7377
code = row['code'].strip()
74-
if not code:
78+
if not code or code == '0':
7579
# Skip lines without code
7680
continue
7781
if code in all_codes:
78-
# Skip coupons which already exist
79-
print ('Coupon %r already exists - skipping' % code)
80-
continue
81-
c = Coupon(conference=conference)
82-
c.code = code
82+
print ('Coupon %r already exists - updating' % code)
83+
c = all_codes[code]
84+
else:
85+
print ('New coupon %r will be created' % c.code)
86+
c = Coupon(conference=conference)
87+
c.code = code
8388
c.max_usage = int(row.get('max_usage', 1))
8489
c.items_per_usage = int(row.get('items_per_usage', 1))
8590
c.value = row['value']
8691
c.description = row.get('description', '')
8792
if not self.dry_run:
8893
c.save()
89-
c.fares = all_fares.filter(
94+
c.fares.set(all_fares.filter(
9095
code__in = [x.strip()
91-
for x in row['fares'].split(',')])
92-
print ('Coupond %r created' % c.code)
96+
for x in row['fares'].split(',')]))

pycon/settings.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -726,3 +726,15 @@ def CONFERENCE_SCHEDULE_ATTENDEES(schedule, forecast):
726726
# Complete project setup.
727727
if not os.path.exists(LOGS_DIR):
728728
os.makedirs(LOGS_DIR)
729+
730+
# Matrix Auth API settings
731+
MATRIX_AUTH_API_DEBUG = config(
732+
'MATRIX_AUTH_API_DEBUG',
733+
default=True,
734+
cast=bool
735+
)
736+
MATRIX_AUTH_API_ALLOWED_IPS = config(
737+
'MATRIX_AUTH_API_ALLOWED_IPS',
738+
default='',
739+
cast=lambda v: [s.strip() for s in v.split(',') if s.strip()]
740+
)

pycon/urls.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from filebrowser.sites import site as fsite
77

88
from conference.accounts import urlpatterns as accounts_urls
9+
from conference.api import urlpatterns as api_urls
910
from conference.cart import urlpatterns as cart_urls
1011
from conference.cfp import urlpatterns as cfp_urls
1112
from conference.debug_panel import urlpatterns as debugpanel_urls
@@ -29,6 +30,7 @@
2930
re_path(r'^generic-content-page/with-sidebar/$', generic_content_page_with_sidebar),
3031
re_path(r'^user-panel/', include((user_panel_urls, 'conference'), namespace="user_panel")),
3132
re_path(r'^accounts/', include((accounts_urls, 'conference'), namespace="accounts")),
33+
re_path(r'^api/', include((api_urls, 'conference'), namespace="api")),
3234
re_path(r'^cfp/', include((cfp_urls, 'conference'), namespace="cfp")),
3335
re_path(r'^talks/', include((talks_urls, 'conference'), namespace="talks")),
3436
re_path(r'^profiles/', include((profiles_urls, 'conference'), namespace="profiles")),

requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ django-treebeard==4.4
9595
# via
9696
# -r requirements.in
9797
# django-cms
98-
django==2.2.19
98+
django==2.2.23
9999
# via
100100
# -r requirements.in
101101
# cmsplugin-filer

0 commit comments

Comments
 (0)