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 ###