From 7e3d2191c685041650c676fd25fe604199339beb Mon Sep 17 00:00:00 2001 From: Cara Salter <cara@devcara.com> Date: Thu, 21 Mar 2024 11:04:37 +1100 Subject: [PATCH] Add shortlinks Officers can now create redirects from the root domain to other domains --- acmsite/admin/__init__.py | 62 ++++++- acmsite/admin/forms.py | 4 + acmsite/main/__init__.py | 13 +- acmsite/models.py | 13 ++ acmsite/templates/admin/admin-layout.html | 1 + acmsite/templates/admin/links.html | 171 ++++++++++++++++++ .../69cccb10f676_add_shortlinks_models.py | 34 ++++ 7 files changed, 293 insertions(+), 5 deletions(-) create mode 100644 acmsite/templates/admin/links.html create mode 100644 migrations/versions/69cccb10f676_add_shortlinks_models.py diff --git a/acmsite/admin/__init__.py b/acmsite/admin/__init__.py index faa5103..c6332de 100644 --- a/acmsite/admin/__init__.py +++ b/acmsite/admin/__init__.py @@ -3,9 +3,9 @@ import ulid import datetime from flask_login import current_user, login_required -from acmsite.models import User, Event +from acmsite.models import Link, User, Event -from .forms import EventForm +from .forms import EventForm, LinkForm from acmsite import db @@ -76,7 +76,9 @@ def delete_event(id): @bp.route("/event/<string:id>", methods=["POST"]) @login_required def update_create_event(id): - + if not current_user.is_admin: + flash("Unauthorized") + return redirect(url_for("dashboard.home")) name = request.form.get('name') description = request.form.get('description') @@ -114,3 +116,57 @@ def update_create_event(id): return redirect(url_for("admin.events")) + +@bp.route("/links") +@login_required +def links(): + if not current_user.is_admin: + flash("Unauthorized") + return redirect(url_for("dashboard.home")) + + links = Link.query.all() + form = LinkForm(request.form) + + return render_template("admin/links.html", links=links, form=form) + +@bp.route("/link/<string:id>") +@login_required +def link(id): + if not current_user.is_admin: + return {"status": "error", "message": "Unauthorized"} + + link = Link.query.filter_by(id=id).first() + + if link is None: + return {"status": "error", "message": "Invalid ID"} + + return link.create_json() + +@bp.route("/link/<string:id>", methods=["POST"]) +@login_required +def update_create_link(id): + if not current_user.is_admin: + flash("Unauthorized") + return redirect(url_for("dashboard.home")) + + slug = request.form.get('slug') + destination = request.form.get('destination') + + if id == '0': + # new link + l = Link( + id=ulid.ulid(), + slug=slug, + destination=destination) + db.session.add(l) + db.session.commit() + else: + l = Link.query.filter_by(id=id).first() + if l is None: + flash("Invalid ID") + return redirect(url_for("admin.links")) + l.slug = slug + l.destination = destination + db.session.commit() + + return redirect(url_for("admin.links")) diff --git a/acmsite/admin/forms.py b/acmsite/admin/forms.py index 022a173..75ed1c7 100644 --- a/acmsite/admin/forms.py +++ b/acmsite/admin/forms.py @@ -10,3 +10,7 @@ class EventForm(FlaskForm): start_time = TimeField('Start Time') end_day = DateField('End Day', validators=[DataRequired()]) end_time = TimeField('End Time') + +class LinkForm(FlaskForm): + slug = StringField("Slug", validators=[DataRequired()]) + destination = StringField("Destination", validators=[DataRequired()]) diff --git a/acmsite/main/__init__.py b/acmsite/main/__init__.py index 3da4c2d..e4e48bb 100644 --- a/acmsite/main/__init__.py +++ b/acmsite/main/__init__.py @@ -1,6 +1,6 @@ import datetime -from flask import Blueprint, render_template -from acmsite.models import Event +from flask import Blueprint, render_template, abort, redirect +from acmsite.models import Event, Link bp = Blueprint('main', __name__) @@ -16,3 +16,12 @@ def events(): @bp.route("/join") def join(): return render_template("join.html") + + +@bp.route("/<string:slug>") +def shortlink(slug): + l = Link.query.filter_by(slug=slug).first() + if l is None: + abort(404) + + return redirect(l.destination) diff --git a/acmsite/models.py b/acmsite/models.py index 42a534c..ecf7da5 100644 --- a/acmsite/models.py +++ b/acmsite/models.py @@ -48,3 +48,16 @@ class Event(db.Model): "start_time": self.start_time.isoformat(), "end_time": self.end_time.isoformat(), } + +class Link(db.Model): + __tablename__ = "acm_links" + id = Column(String, primary_key=True) + slug = Column(String, nullable=False, unique=True) + destination = Column(String, nullable=False) + + def create_json(self): + return { + "id": self.id, + "slug": self.slug, + "destination": self.destination + } diff --git a/acmsite/templates/admin/admin-layout.html b/acmsite/templates/admin/admin-layout.html index 6592243..29f371a 100644 --- a/acmsite/templates/admin/admin-layout.html +++ b/acmsite/templates/admin/admin-layout.html @@ -22,6 +22,7 @@ {{ render_nav_item('admin.users', 'Member List')}} {{ render_nav_item('admin.events', 'Event List')}} {{ render_nav_item('admin.home', 'Bulk Mail Tool')}} + {{ render_nav_item('admin.links', 'Shortlinks')}} </ul> <ul class="nav navbar-nav"> {{ render_nav_item('dashboard.home', '<- Back To Site') }} diff --git a/acmsite/templates/admin/links.html b/acmsite/templates/admin/links.html new file mode 100644 index 0000000..6879f75 --- /dev/null +++ b/acmsite/templates/admin/links.html @@ -0,0 +1,171 @@ +{% extends 'admin/admin-layout.html' %} +{% import 'bootstrap5/form.html' as wtf %} + +{% block app_content %} +<h1>ACM Shortlinks</h1> +<p>Use these to create redirects from the ACM site to other destinations. Make +sure they don't conflict with existing routes -- avoid the following:</p> +<ul> + <li>/dashboard</li> + <li>/admin</li> + <li>/static</li> + <li>/join</li> + <li>/events</li> +</ul> + +<hr> +<table class="table table-striped"> + <thead> + <tr> + <th>Slug</th> + <th>Destination</th> + <th><button type="button" class="btn btn-primary" + data-bs-toggle="modal" data-bs-target="#editModal" + data-id="0">New</button></th> + </tr> + </thead> + <tbody> + {% for l in links %} + <tr> + <td>{{ l.slug }}</td> + <td>{{ l.destination }}</td> + <td> + <div class="dropdown"> + <a class="btn btn-primary dropdown-toggle" + data-bs-toggle="dropdown" href="#"><span + class="caret"></span></a> + <ul class="dropdown-menu"> + <li class="dropdown-item"> + <a href="#editModal" data-bs-toggle="modal" + data-id="{{ l.id + }}">Edit</a> + </li> + <li class="dropdown-item"> + <a href="#deleteModal" data-bs-toggle="modal" + data-id="{{ + l.id}}">Delete</a> + </li> + </ul> + </div> + </td> + </tr> + {% endfor %} + </tbody> +</table> + +<div class="modal" id="editModal" tabindex="-1" aria-labelledby="editModalLabel" + aria-hidden="true"> + <div class="modal-dialog"> + <div class="modal-content"> + <div class="modal-header"> + <h1 class="modal-title fs-5" id="editModalLabel">Event</h1> + <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> + </div> + <form class="form" id="edit-form" action="/admin/events/0" role="form" method="post"> + <div class="modal-body"> + {{ form.csrf_token }} + <div class="form-floating mb-3 required"> + {{ form.slug(class="form-control") }} + {{ form.slug.label() }} + </div> + <div class="form-floating required"> + {{ form.destination(class="form-control") }} + {{ form.destination.label() }} + </div> + <div class="modal-footer"> + <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button> + <button type="submit" class="btn btn-primary" id="edit-save">Save changes</button> + </div> + </form> + </div> + </div> +</div> + +<div class="modal" id="deleteModal" tabindex="-1" + aria-labelledby="deleteModalLabel" + aria-hidden="true"> + <div class="modal-dialog"> + <div class="modal-content"> + <div class="modal-header"> + <h1 class="modal-title fs-5" id="deleteModalLabel">Delete + Event?</h1> + <button type="button" class="btn-close" data-bs-dismiss="modal" + aria-label="Close"> + </div> + + <div class="modal-footer"> + <button type="button" class="btn btn-secondary" + data-bs-dismiss="modal">Cancel</button> + <button type="button" id="delete" data-bs-dismiss="modal" class=" btn btn-danger">Delete</button> + </div> + </div> + </div> +</div> +<script src="{{ url_for('static', filename='js/jquery-3.6.3.min.js') }}" charset="utf-8"></script> +<script charset="utf-8"> + const deleteButton = document.getElementById("delete") + const editButton = document.getElementById("edit-save") + + deleteButton.addEventListener("click", (event) => { + button = $(event.relatedTarget) + id = deleteButton.dataset.id + const deleteRequest = new Request(`/admin/link/${id}/delete`) + + fetch(deleteRequest) + .then(async (res) => { + window.alert(await res.text()) + }); + }); + + $('#deleteModal').on('show.bs.modal', function(event) { + var modal = $(this) + var button = $(event.relatedTarget) + var id = button.data("id") + + // find delete button + + delButton = document.getElementById("delete") + delButton.dataset.id = id + }); + + $('#editModal').on('show.bs.modal', function(event) { + var modal = $(this) + + // Zero all fields + modal.find('#slug').val('') + modal.find('#destination').val('') + var button = $(event.relatedTarget) + var slug,destination + id = button.data('id') + + saveButton = document.getElementById("edit-save") + saveButton.dataset.id = id + + editForm = document.getElementById("edit-form") + editForm.action = "/admin/link/" + id + + if (id) { + $.get(`/admin/link/${id}`, (data) => { + console.log(data) + if (data.status == "error") { + // This is a new event, do nothing! + } else { + slug = data.slug + destination = data.destination + } + + modal.find('#slug').val(slug) + modal.find('#destination').val(destination) + }); + } + }); + + $('#deleteModal').on('hidden.bs.modal', function(event) { + location.reload() + }); + + $('#editModal').on('hidden.bs.modal', function(event) { + location.reload() + }); +</script> +{% endblock %} diff --git a/migrations/versions/69cccb10f676_add_shortlinks_models.py b/migrations/versions/69cccb10f676_add_shortlinks_models.py new file mode 100644 index 0000000..9dada3f --- /dev/null +++ b/migrations/versions/69cccb10f676_add_shortlinks_models.py @@ -0,0 +1,34 @@ +"""add shortlinks models + +Revision ID: 69cccb10f676 +Revises: 6d239e987242 +Create Date: 2024-03-21 10:23:18.010881 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '69cccb10f676' +down_revision = '6d239e987242' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('acm_links', + sa.Column('id', sa.String(), nullable=False), + sa.Column('slug', sa.String(), nullable=False), + sa.Column('destination', sa.String(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('slug') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('acm_links') + # ### end Alembic commands ###