From ec503363b4535b3cca99b6844f774d33986429be Mon Sep 17 00:00:00 2001 From: Nikolas Eriksson Date: Mon, 23 Mar 2026 16:10:11 +0100 Subject: [PATCH 1/2] Update mail logic and download csv --- app/forms.py | 21 +++ app/mail_ohlreise.py | 101 +++++++++--- app/templates/ohlreise/edit_member.html | 26 +-- app/templates/ohlreise/members.html | 67 ++++++-- app/templates/ohlreise/submit.html | 154 +++++++++-------- app/views/ohlreise_views.py | 211 ++++++++++++++++++------ 6 files changed, 419 insertions(+), 161 deletions(-) diff --git a/app/forms.py b/app/forms.py index f487645..8e3493e 100644 --- a/app/forms.py +++ b/app/forms.py @@ -116,3 +116,24 @@ class DeleteForm(FlaskForm): class LoginForm(FlaskForm): openid = StringField('openid', validators=[DataRequired()]) remember_me = BooleanField('remember_me', default=False) + +class AdminBeerEditForm(ModelForm, FlaskForm): + class Meta: + model = Beer + + name = StringField('För- och efternamn', validators=[DataRequired()]) + nickname = StringField('Kårnamn') + email = StringField('epost@din.com', validators=[Email()]) + person_number = StringField( + 'Personnummer', + validators=[ + DataRequired(), + Regexp( + "^[12]{1}[90]{1}[0-9]{6}-[0-9]{4}$", + message="Skriv personnummer på formatet ååååmmdd-xxxx" + ), + validate_age + ] + ) + mobile_number = StringField('Mobilnummer', validators=[DataRequired()]) + has_payed = BooleanField('Betalat') diff --git a/app/mail_ohlreise.py b/app/mail_ohlreise.py index 2a96ce4..7cff4cc 100644 --- a/app/mail_ohlreise.py +++ b/app/mail_ohlreise.py @@ -1,10 +1,16 @@ # -*- coding: utf-8 -*- -from app import app -import smtplib +import os +import json +import base64 from email.mime.text import MIMEText +from app import app +from google.auth.transport.requests import Request +from google.oauth2.credentials import Credentials +from googleapiclient.discovery import build + +SCOPES = ['https://www.googleapis.com/auth/gmail.send'] -FROM_ADDRESS = 'info.brutalakademien' SUBJECT = "Bokningskonfirmation" BODY = """ Tack för din anmälan! @@ -20,28 +26,79 @@ """ -def send(to_address="andreas.cederstrom@gmail.com", price=1337, - team_name="Aporna"): +def send(to_address="andreas.cederstrom@gmail.com", price=1337, team_name="Aporna"): bank = app.config['ÖHLREISE']['payment']['bank'] account_number = app.config['ÖHLREISE']['payment']['account_number'] last_payment_date = app.config['ÖHLREISE']['payment']['last_payment_date'] - mail = _build_mail(to_address, price, bank, account_number, last_payment_date, team_name) - _do_send(mail) + subject, body = _build_mail_content( + price, + bank, + account_number, + last_payment_date, + team_name + ) + return _do_send(to_address, subject, body) -def _build_mail(to_address, price, bank, account_number, last_payment_date , team_name): + +def _build_mail_content(price, bank, account_number, last_payment_date, team_name): body = BODY % (price, bank, account_number, last_payment_date, team_name) - mail = MIMEText(body) - mail['Subject'] = SUBJECT - mail['From'] = FROM_ADDRESS - mail['To'] = to_address - return mail - - -def _do_send(mail): - server = smtplib.SMTP('smtp.gmail.com', 587) - server.ehlo() - server.starttls() - server.login(app.config['GMAIL_USERNAME'], app.config['GMAIL_PASSWORD']) - server.sendmail(mail['From'], mail['To'], mail.as_string()) - server.quit() + return SUBJECT, body + + +def _load_credentials(): + token_path = app.config['GOOGLE_OAUTH_TOKEN_FILE'] + + if not os.path.exists(token_path): + raise RuntimeError( + "Gmail OAuth token.json is missing. Connect Gmail via the admin OAuth route first." + ) + + with open(token_path, 'r') as f: + token_data = json.load(f) + + creds = Credentials.from_authorized_user_info(token_data, SCOPES) + + if creds.expired and creds.refresh_token: + creds.refresh(Request()) + _save_credentials(creds) + + if not creds.valid: + raise RuntimeError("Gmail OAuth credentials are invalid. Reconnect Gmail.") + + return creds + + +def _save_credentials(creds): + token_path = app.config['GOOGLE_OAUTH_TOKEN_FILE'] + with open(token_path, 'w') as f: + f.write(creds.to_json()) + + +def _build_gmail_service(): + creds = _load_credentials() + return build('gmail', 'v1', credentials=creds) + + +def _create_message(to_address, subject, body): + from_address = app.config['GMAIL_FROM_ADDRESS'] + + message = MIMEText(body, _charset='utf-8') + message['To'] = to_address + message['From'] = from_address + message['Reply-To'] = from_address + message['Subject'] = subject + + raw = base64.urlsafe_b64encode(message.as_bytes()).decode('utf-8') + return {'raw': raw} + + +def _do_send(to_address, subject, body): + service = _build_gmail_service() + message = _create_message(to_address, subject, body) + + service.users().messages().send( + userId='me', + body=message + ).execute() + \ No newline at end of file diff --git a/app/templates/ohlreise/edit_member.html b/app/templates/ohlreise/edit_member.html index dd1cfed..c8395b2 100644 --- a/app/templates/ohlreise/edit_member.html +++ b/app/templates/ohlreise/edit_member.html @@ -8,30 +8,30 @@

{{ title }}

- Namn - {{ form.name(placeholder="Namn *") }} + + {{ form.name(id="name", placeholder="Namn *") }}
- Kårnamn - {{ form.nickname(placeholder="Kårnamn") }} + + {{ form.nickname(id="nickname", placeholder="Kårnamn") }}
- Personnummer - {{ form.person_number(placeholder="Personnummer * (ååååmmdd-xxxx)") }} + + {{ form.person_number(id="person_number", placeholder="Personnummer * (ååååmmdd-xxxx)") }} {% for error in form.person_number.errors %} [{{ error }}] {% endfor %}
- Allergier - {{ form.allergies(placeholder="Allergier") }} - {% for error in form.allergies.errors %} - [{{ error }}] - {% endfor %} + + {{ form.email(id="email", placeholder="Email *") }}
- Email - {{ form.email(placeholder="Email *") }} + + {{ form.mobile_number(id="mobile_number", placeholder="Mobilnummer *") }} + {% for error in form.mobile_number.errors %} + [{{ error }}] + {% endfor %}
diff --git a/app/templates/ohlreise/members.html b/app/templates/ohlreise/members.html index f1d5576..c30274f 100644 --- a/app/templates/ohlreise/members.html +++ b/app/templates/ohlreise/members.html @@ -2,12 +2,26 @@ {% block title %}Alla anmälda bussresenärer{% endblock %} {% block content %} -{% set is_admin = current_user.is_authenticated %} +{% set is_admin = current_user.is_authenticated and current_user.is_admin %} @@ -18,7 +32,29 @@

Alla anmälda bussresenärer

Totalt har vi {{ beer|length }} anmälda bussresenärer.

+ + {% if is_admin %} +

+ Antal som har betalat: {{ beer|selectattr("has_payed")|list|length }}
+ Antal som inte har betalat: {{ beer|rejectattr("has_payed")|list|length }} +

+ +
+ +
+ + + {% endif %} +
@@ -28,26 +64,31 @@

Alla anmälda bussresenärer

{% if is_admin %} - {% endif %} - {% for beer in beer %} + {% for member in beer %} - - + + {% if is_admin %} - - - - - - - - + + + + + {% endif %} {% endfor %} diff --git a/app/templates/ohlreise/submit.html b/app/templates/ohlreise/submit.html index 2c57f9b..b61b874 100644 --- a/app/templates/ohlreise/submit.html +++ b/app/templates/ohlreise/submit.html @@ -3,24 +3,27 @@ {% block content %}
@@ -30,64 +33,83 @@

Anmälan

Biljetterna har släppts!

- {% for ticket in config.ÖHLREISE.ticket_types %} -

{{ticket.name}} - {{ticket.price}} Riksdaler

- Biljetter kvar: {{remaining_tickets_for_type[loop.index0]}} st + + {% for ticket in config.ÖHLREISE.ticket_types %} +

{{ ticket.name }} - {{ ticket.price }} Riksdaler

+ Biljetter kvar: {{ remaining_tickets_for_type[loop.index0] }} st

Och i denna fantastiska biljett ingår då: -
- {{ticket.description}} +
+ {{ ticket.description }}

- {% endfor %} + {% endfor %}
-
-

Säkerhetsklassad information

-
-
- {{ form.name(placeholder="För- och efternamn *") }} - {% for error in form.name.errors %} - [{{ error }}] - {% endfor %} -
-
- {{ form.nickname(placeholder="Kårnamn") }} -
-
- {{ form.email(placeholder="epost@din.com *", type="email", autocomplete="on") }} - {% for error in form.email.errors %} - [{{ error }}] - {% endfor %} -
-
- {{ form.person_number(placeholder="Personnummer * (ååååmmdd-xxxx)") }} - {% for error in form.person_number.errors %} - [{{ error }}] - {% endfor %} -
-
- {{ form.mobile_number(placeholder="Mobilnummer *") }} - {% for error in form.mobile_number.errors %} - [{{ error }}] - {% endfor %} -
-
+
+

Säkerhetsklassad information

+ +
+
+ {{ form.name(id="name", placeholder="För- och efternamn *") }} + {% for error in form.name.errors %} + [{{ error }}] + {% endfor %} +
+ +
+ {{ form.nickname(id="nickname", placeholder="Kårnamn") }} + {% for error in form.nickname.errors %} + [{{ error }}] + {% endfor %} +
+ +
+ {{ form.email(id="email", placeholder="epost@din.com *", type="email", autocomplete="on") }} + {% for error in form.email.errors %} + [{{ error }}] + {% endfor %} +
+ +
+ {{ form.person_number(id="person_number", placeholder="Personnummer * (ååååmmdd-xxxx)") }} + {% for error in form.person_number.errors %} + [{{ error }}] + {% endfor %} +
+ +
+ {{ form.mobile_number(id="mobile_number", placeholder="Mobilnummer *") }} + {% for error in form.mobile_number.errors %} + [{{ error }}] + {% endfor %} +
+
+
+ +
+

+ Efter att du har skickat din anmälan kommer du få ett epost med betalningsuppgifter. +

+ +

+ {{ form.accept_terms(id="accept_terms") }} + + {% for error in form.accept_terms.errors %} + [{{ error }}] + {% endfor %} +

+ + {% if error_message and error_message != "None" %} +

+ {{ error_message }} +

+ {% endif %} -
-

- Efter att du har skickat din anmälan kommer du få ett epost med betalningsuppgifter. -

-

- {{ form.accept_terms }} - - {% for error in form.accept_terms.errors %} - [{{ error }}] - {% endfor %} -

{{ form.submit(class="fit big special") }}
-{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/app/views/ohlreise_views.py b/app/views/ohlreise_views.py index fe02c53..43f0efd 100644 --- a/app/views/ohlreise_views.py +++ b/app/views/ohlreise_views.py @@ -1,8 +1,12 @@ # -*- coding: utf-8 -*- -from flask import jsonify, render_template, redirect, url_for, request +import csv +import io +from flask import Response + +from flask import render_template, redirect, url_for, request, flash from app import app, db, logic, mail_ohlreise from app.decorators import login_required -from app.forms import BeerForm, DeleteForm +from app.forms import BeerForm, DeleteForm, AdminBeerEditForm from app.models import Beer @@ -10,11 +14,16 @@ def beer(): return render_template("ohlreise/info.html") -@app.route('/ohlreise/members', methods=['GET', 'POST']) +@app.route('/ohlreise/members', methods=['GET']) def ohlreise_members(): - form = DeleteForm() - beer = Beer.query.order_by(Beer.name).all() - return render_template("ohlreise/members.html", beer=beer, form=form) + beers, has_payed_arg = _get_filtered_beers() + beers = beers.order_by(Beer.name).all() + + return render_template( + "ohlreise/members.html", + beer=beers, + has_payed=has_payed_arg + ) @app.route('/ohlreise/member/', methods=['GET', 'POST']) @login_required @@ -23,44 +32,68 @@ def ohlreise_edit_member(id): assert member if request.method == 'POST': - form = BeerForm(request.form) - form.populate_obj(member) - db.session.add(member) - db.session.commit() - return redirect(url_for('ohlreise_members', _anchor=member.id)) - else: - form = BeerForm(obj=member) - return render_template("ohlreise/edit_member.html", form=form, - title='Editera medlem') - -@app.route('/ohlreise/members/add-member', methods=['POST']) + form = AdminBeerEditForm(request.form) + form.Meta.csrf = False + + if form.validate(): + form.populate_obj(member) + db.session.add(member) + db.session.commit() + return redirect(url_for('ohlreise_members', _anchor=str(member.id))) + + app.logger.warning("AdminBeerEditForm errors: %r", form.errors) + return render_template( + "ohlreise/edit_member.html", + form=form, + title='Editera medlem' + ) + + form = AdminBeerEditForm(obj=member) + return render_template( + "ohlreise/edit_member.html", + form=form, + title='Editera medlem' + ) + +@app.route('/ohlreise/members/add-member', methods=['GET', 'POST']) @login_required def ohlreise_add_member(): + if request.method == 'POST': + form = BeerForm(request.form) - if request.method == 'POST': + if form.validate(): member = Beer() - form = BeerForm(request.form) form.populate_obj(member) db.session.add(member) db.session.commit() return redirect(url_for('ohlreise_members')) - else: - form = BeerForm() - return render_template("ohlreise/edit_member.html", form=form, - title='Lägg till medlem') + + return render_template( + "ohlreise/edit_member.html", + form=form, + title='Lägg till medlem' + ) + + form = BeerForm() + return render_template( + "ohlreise/edit_member.html", + form=form, + title='Lägg till medlem' + ) @app.route('/ohlreise/member//delete', methods=['POST']) @login_required def ohlreise_delete_member(id): - if request.method == 'POST': - beer = Beer.query.get(id) - if beer: - db.session.delete(beer) - db.session.commit() - return redirect(url_for('ohlreise_members')) # Redirect to the appropriate route - else: - flash('Gick inte att hitta resenäreren.', 'error') - return render_template('ohlreise_members', form = form) + member = Beer.query.get(id) + + if not member: + flash('Gick inte att hitta resenären.', 'error') + return redirect(url_for('ohlreise_members')) + + db.session.delete(member) + db.session.commit() + flash('Resenären togs bort.', 'success') + return redirect(url_for('ohlreise_members')) @app.route('/ohlreise/submit', methods=['GET', 'POST']) def ohlreise_submit(): @@ -68,39 +101,123 @@ def ohlreise_submit(): milliseconds = logic.get_milliseconds_until_ohlreise_submit_opens() return render_template("ohlreise/countdown.html", milliseconds=milliseconds) - remaining_tickets_for_type = [logic.get_number_of_tickets_for_this_type_left_beer(ind) for ind, ticket in enumerate(app.config['ÖHLREISE']['ticket_types'])] + remaining_tickets_for_type = [ + logic.get_number_of_tickets_for_this_type_left_beer(ind) + for ind, ticket in enumerate(app.config['ÖHLREISE']['ticket_types']) + ] if sum(remaining_tickets_for_type) <= 0 or logic.has_submit_ohlreise_closed(): return render_template("ohlreise/submit_temp_closed.html") form = BeerForm(request.form) - error_message = "None" form.Meta.csrf = False + error_message = "None" if request.method == 'POST': - person_numbers = [form.data['person_number']] + person_number = form.data.get('person_number') if form.validate(): - duplicate_person_number = any(Beer.query.filter_by(person_number=pn).first() for pn in person_numbers if pn) + duplicate_person_number = ( + Beer.query.filter_by(person_number=person_number).first() + if person_number else None + ) if duplicate_person_number: - form.errors['person_number'] = ['Det finns redan en användare med detta personnummer.'] + form.person_number.errors = list(form.person_number.errors) + [ + 'Det finns redan en användare med detta personnummer.' + ] error_message = "Det finns redan en användare med detta personnummer." - - if not duplicate_person_number: + else: beer = Beer() form.populate_obj(beer) db.session.add(beer) db.session.commit() - mail_ohlreise.send(beer.email, beer.price, beer.name) + + try: + mail_ohlreise.send(beer.email, beer.price, beer.name) + except Exception: + app.logger.exception( + "Failed to send Ohlreise confirmation email" + ) + return render_template("ohlreise/confirmation.html", beer=beer) + else: - print("Form validation errors: ", form.errors) + app.logger.warning("Beer form validation errors: %r", form.errors) error_message = "Gör om, gör rätt." - else: - remaining_tickets_for_type = [logic.get_number_of_tickets_for_this_type_left_beer(ind) for ind, ticket in enumerate(app.config['ÖHLREISE']['ticket_types'])] - return render_template("ohlreise/submit.html", remaining_tickets_for_type=remaining_tickets_for_type, form=form, error_message=error_message) - # Handle the GET case - remaining_tickets_for_type = [logic.get_number_of_tickets_for_this_type_left_beer(ind) for ind, ticket in enumerate(app.config['ÖHLREISE']['ticket_types'])] - return render_template("ohlreise/submit.html", remaining_tickets_for_type=remaining_tickets_for_type, form=form) + return render_template( + "ohlreise/submit.html", + remaining_tickets_for_type=remaining_tickets_for_type, + form=form, + error_message=error_message + ) + + return render_template( + "ohlreise/submit.html", + remaining_tickets_for_type=remaining_tickets_for_type, + form=form, + error_message=error_message + ) + +@app.route('/ohlreise/members/delete', methods=['POST']) +@login_required +def ohlreise_remove_all_members(): + members = Beer.query.fetchall() + + if not members: + flash('Gick inte att hitta några resenärer.', 'error') + return redirect(url_for('ohlreise_members')) + + db.session.delete(members) + db.session.commit() + flash('Resenärerna togs bort.', 'success') + return redirect(url_for('ohlreise_members')) + +def _get_filtered_beers(): + beers = db.session.query(Beer) + has_payed_arg = request.args.get('has_payed') + + if has_payed_arg is not None and has_payed_arg != '-': + has_payed = has_payed_arg == 'True' + beers = beers.filter_by(has_payed=has_payed) + + return beers, has_payed_arg + + +@app.route('/ohlreise/export/members/csv') +@login_required +def ohlreise_export_csv(): + beers = db.session.query(Beer).order_by(Beer.name).all() + output = io.StringIO() + writer = csv.writer(output) + + writer.writerow([ + 'Namn', + 'Kårnamn', + 'Personnummer', + 'E-post', + 'Mobilnummer', + 'Betalat', + ]) + + for beer in beers: + writer.writerow([ + beer.name, + beer.nickname, + beer.person_number, + beer.email, + '%s' % beer.mobile_number if beer.mobile_number else '', + 'Ja' if beer.has_payed else 'Nej', + ]) + + csv_data = output.getvalue() + output.close() + + return Response( + csv_data, + mimetype='text/csv', + headers={ + 'Content-Disposition': 'attachment; filename=ohlreise_members_export.csv' + } + ) From 6ccf6f18c293ade961a170ac01b24fa5527ab5ba Mon Sep 17 00:00:00 2001 From: Nikolas Eriksson Date: Mon, 23 Mar 2026 16:23:32 +0100 Subject: [PATCH 2/2] Fix remove all members --- app/templates/ohlreise/countdown.html | 2 +- app/templates/ohlreise/members.html | 9 ++++++++- app/views/ohlreise_views.py | 8 ++++---- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/app/templates/ohlreise/countdown.html b/app/templates/ohlreise/countdown.html index 052521f..fad0900 100644 --- a/app/templates/ohlreise/countdown.html +++ b/app/templates/ohlreise/countdown.html @@ -2,7 +2,7 @@ {% block title %}Anmälan{% endblock %} {% block content %}
KårnamnPersonnummerAllergier Email Har betalat
{{ beer.name }}{{ beer.nickname }}{{ member.name }}{{ member.nickname }}{{ beer.person_number }}{{ beer.allergies }}{{ beer.email }} {{ 'Ja' if beer.has_payed else 'Nej' }}Edit{{ member.person_number }}{{ member.email }}{{ 'Ja' if member.has_payed else 'Nej' }} + Justera + +
+ +
+