Enable Officer positions to be created and photos to be uploaded

holy shit

Closes: #4
TODO: Create API for getting current officers and upcoming events
This commit is contained in:
Cara Salter 2024-04-01 14:14:56 +11:00
parent 06ef8f047a
commit 026523b26f
No known key found for this signature in database
GPG key ID: A8A3A601440EADA5
11 changed files with 431 additions and 10 deletions

1
.gitignore vendored
View file

@ -1,6 +1,7 @@
# Created by https://www.toptal.com/developers/gitignore/api/python,flask
# Edit at https://www.toptal.com/developers/gitignore?templates=python,flask
uploads/
### Flask ###
instance/*
!instance/.gitignore

View file

@ -32,7 +32,7 @@ daemon:
@echo "--- STARTING UWSGI DAEMON ---"
@echo ""
@echo ""
source .venv/bin/activate && flask run
source .venv/bin/activate && FLASK_DEBUG=True flask run
@echo ""
@echo ""
@echo "--- STARTING UWSGI DAEMON ---"

View file

@ -1,4 +1,5 @@
from flask import Flask
import os
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from flask_login import LoginManager
@ -42,6 +43,12 @@ def create_app():
)
from .models import User
# Ensure that uploads directory exists
try:
os.mkdir(app.config["UPLOAD_FOLDER"])
except FileExistsError:
pass
from .main import bp as main_bp
app.register_blueprint(main_bp)

View file

@ -1,12 +1,17 @@
from flask import Blueprint, flash, redirect, render_template, request, send_file, url_for
from operator import pos
import os
from flask import Blueprint, current_app, flash, redirect, render_template, request, send_file, url_for
import ulid
import datetime
from flask_login import current_user, login_required
from io import BytesIO
from PIL import Image
import base64
from acmsite.models import Link, User, Event
from acmsite.models import Link, Officer, User, Event
from acmsite import models
from .forms import EventForm, LinkForm
from .forms import EventForm, LinkForm, OfficerForm
from acmsite import db
@ -29,7 +34,10 @@ def users():
user_list = User.query.all()
return render_template("admin/users.html", u_list=user_list)
position_form = OfficerForm(request.form)
return render_template("admin/users.html", u_list=user_list,
form=position_form)
@bp.route("/users.csv")
@login_required
@ -44,7 +52,6 @@ def users_csv():
return send_file('./tmp/members.csv')
@bp.route("/events")
@login_required
def events():
@ -184,3 +191,107 @@ def update_create_link(id):
db.session.commit()
return redirect(url_for("admin.links"))
def error_json(message):
return {"status": "error", "message": message}
def success_json():
return {"status": "success"}
@bp.route("/officer/<string:user_id>")
@login_required
def officer_positions(user_id):
if not current_user.is_admin:
flash("Unauthorized")
return redirect(url_for("dashboard.home"))
form = OfficerForm(request.form)
position_list = Officer.query.filter_by(user_id=user_id).order_by(Officer.term_end).all()
return render_template("admin/officers.html", form=form,
position_list=position_list, user_id=user_id)
@bp.route("/officer/get/<string:pos_id>")
@login_required
def get_position(pos_id):
if not current_user.is_admin:
return error_json("Unauthorized")
pos = Officer.query.filter_by(id=pos_id).first()
if pos is None:
return error_json("Invalid ID")
return pos.create_json()
@bp.route("/officer/new/<string:user_id>", methods=["POST"])
@login_required
def create_officer(user_id):
if not current_user.is_admin:
error_json("Unauthorized")
form = OfficerForm(request.form)
if form.validate_on_submit:
position = request.form.get("position")
term_end = request.form.get("term_end")
term_start = request.form.get("term_start")
position = Officer(
id=ulid.ulid(),
user_id=user_id,
position=position,
term_start=term_start,
term_end=term_end)
db.session.add(position)
db.session.commit()
return redirect(url_for("admin.officer_positions", user_id=user_id))
@bp.route("/officer/update/<string:user_id>/<string:officer_id>", methods=["POST"])
@login_required
def update_officer(user_id, officer_id):
if not current_user.is_admin:
flash("Unauthorized")
return redirect(url_for('dashboard.home'))
form = OfficerForm(request.form)
if form.validate_on_submit:
officer = Officer.query.filter_by(id=officer_id).first()
if officer is None:
flash("Invalid ID")
return redirect(url_for('admin.officer_positions', user_id=user_id))
officer.position = request.form.get("position")
officer.term_start = request.form.get("term_start")
officer.term_end = request.form.get("term_end")
db.session.commit()
return redirect(url_for('admin.officer_positions', user_id=user_id))
@bp.route("/officer/photo")
@login_required
def upload_photo():
if not current_user.is_admin:
return error_json("Unauthorized")
return render_template("admin/officer_photo.html")
@bp.route("/officer/photo/upload", methods=["POST"])
@login_required
def upload_photo_post():
if not current_user.is_admin:
return error_json("Unauthorized")
img_path = os.path.join(current_app.config["UPLOAD_FOLDER"], f"{current_user.username()}.png")
b64_string = request.data.decode()
b64_string += '=' * (len(b64_string) % 4)
im = Image.open(BytesIO(base64.b64decode(b64_string.split(',')[1])))
im.save(img_path, format="PNG")
return success_json()

View file

@ -1,5 +1,5 @@
from flask_wtf import FlaskForm
from wtforms import DateTimeField, DateField, StringField, TextAreaField, TimeField
from wtforms import DateTimeField, DateField, SelectField, StringField, TextAreaField, TimeField
from wtforms.validators import DataRequired
class EventForm(FlaskForm):
@ -14,3 +14,11 @@ class EventForm(FlaskForm):
class LinkForm(FlaskForm):
slug = StringField("Slug", validators=[DataRequired()])
destination = StringField("Destination", validators=[DataRequired()])
class OfficerForm(FlaskForm):
position = SelectField("Position", choices=["President", "Vice President",
"Treasurer", "Secretary", "PR Chair", "Hackathon Manager 1",
"Hackathon Manager 2", "System Administrator"],
validators=[DataRequired()])
term_start = DateField("Term Start", validators=[DataRequired()])
term_end = DateField("Term End", validators=[DataRequired()])

View file

@ -17,6 +17,9 @@ class User(db.Model, UserMixin):
active = Column(Boolean, nullable=False, default=True)
is_admin = Column(Boolean, nullable=False, default=False)
def username(self):
return self.email.split("@")[0]
def create_acm_csv(user_list):
with open('acmsite/tmp/members.csv', 'w') as members_csv:
header = ['last', 'first', 'email']
@ -43,6 +46,15 @@ class Officer(db.Model):
term_end = Column(Date, nullable=True)
position = Column(String, nullable=False)
def create_json(self):
return {
"id": self.id,
"user_id": self.user_id,
"term_start": self.term_start,
"term_end": self.term_end,
"position": self.position
}
class PwResetRequest(db.Model):
id = Column(String, primary_key=True)
user_id = Column(String, ForeignKey('acm_users.id'), nullable=False)

View file

@ -0,0 +1,103 @@
{% extends "admin/admin-layout.html" %}
{% block app_content %}
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/normalize/5.0.0/normalize.min.css">
<link rel='stylesheet' href='https://cdnjs.cloudflare.com/ajax/libs/cropper/2.3.4/cropper.min.css'>
<h1>Update Officer Photo</h1>
<!-- input file -->
<div class="box">
<input type="file" id="file-input">
</div>
<!-- leftbox -->
<div class="box-2">
<div class="result"></div>
</div>
<!--rightbox-->
<div class="box-2 img-result hide">
<!-- result of crop -->
<img class="cropped" src="" alt="">
</div>
<!-- input file -->
<div class="box">
<div class="options hide">
<label> Width</label>
<input type="number" class="img-w" value="512" min="512" max="512" />
</div>
<!-- save btn -->
<button class="btn save hide">Save</button>
<!-- download btn -->
<a href="" class="btn download hide">Download</a>
</div>
<script src="{{ url_for('static', filename='js/jquery-3.6.3.min.js') }}" charset="utf-8"></script>
<script src='https://cdnjs.cloudflare.com/ajax/libs/cropperjs/0.8.1/cropper.min.js'></script>
<script charset="utf-8">
// vars
let result = document.querySelector('.result'),
img_result = document.querySelector('.img-result'),
img_w = document.querySelector('.img-w'),
img_h = document.querySelector('.img-h'),
options = document.querySelector('.options'),
save = document.querySelector('.save'),
cropped = document.querySelector('.cropped'),
dwn = document.querySelector('.download'),
upload = document.querySelector('#file-input'),
cropper = '';
// on change show image with crop options
upload.addEventListener('change', e => {
if (e.target.files.length) {
// start file reader
const reader = new FileReader();
reader.onload = e => {
if (e.target.result) {
// create new image
let img = document.createElement('img');
img.id = 'image';
img.src = e.target.result;
// clean result before
result.innerHTML = '';
// append new image
result.appendChild(img);
// show save btn and options
save.classList.remove('hide');
// options.classList.remove('hide');
// init cropper
cropper = new Cropper(img, { aspectRatio: 1 });
}
};
reader.readAsDataURL(e.target.files[0]);
}
});
// save on click
save.addEventListener('click', e => {
e.preventDefault();
// get result to data uri
let imgSrc = cropper.getCroppedCanvas({
width: img_w.value // input value
}).toDataURL();
// remove hide class of img
cropped.classList.remove('hide');
// img_result.classList.remove('hide');
// show image cropped
cropped.src = imgSrc;
console.log(imgSrc);
const uploadReq = new Request("/admin/officer/photo/upload", {
method: "POST",
body: imgSrc
});
fetch(uploadReq).then(async (res) => {
console.log(res.status)
if (res.status === 200) {
user_id = "{{ current_user.id }}"
window.location = `/admin/officer/${user_id}`
}
});
dwn.classList.remove('hide');
dwn.download = 'imagename.png';
dwn.setAttribute('href', imgSrc);
});
</script>
{% endblock %}

View file

@ -0,0 +1,173 @@
{% extends "admin/admin-layout.html" %}
{% block app_content %}
<h1>Officer Positions for {{ current_user.first_name}} {{ current_user.last_name
}}</h1>
<p>Update Photo: <a href="{{ url_for('admin.upload_photo')
}}">Here</a>
<table class="table table-striped">
<thead>
<tr>
<th>Position</th>
<th>Term Start</th>
<th>Term End</th>
<th><button type="button" class="btn btn-primary"
data-bs-toggle="modal"
data-bs-target="#officerModal"
data-id="0" data-user-id="{{
user_id
}}">New</button></th>
</tr>
</thead>
<tbody>
{% for o in position_list %}
<tr>
<td>{{ o.position }}</td>
<td>{{ o.term_start }}</td>
<td>{{ o.term_end }}</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="#officerModal"
data-bs-toggle="modal"
data-id="{{
o.id}}"
data-user-id="{{
user_id
}}">Edit</a></li>
</ul>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<!-- Modals -->
<div class="modal" id="officerModal" tabindex="-1"
aria-labelledby="officerModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="officerModalLabel">Update Officer</h1>
<button class="btn-close" type="button" data-bs-dismiss="modal"
aria-label="Close"></button>
</div>
<form class="form" id="officer-form" action="/admin/officers/0"
method="post" autocomplete="off">
<div class="modal-body">
{{ form.csrf_token}}
<div class="form-floating mb-3 required">
{{ form.position(class="form-control") }}
{{ form.position.label() }}
</div>
<div class="row">
<div class="col">
<div class="form-floating mb-3 required">
{{ form.term_start(class="form-control") }}
{{ form.term_start.label() }}
</div>
</div>
<div class="col">
<div class="form-floating mb-3 required">
{{ form.term_end(class="form-control") }}
{{ form.term_end.label() }}
</div>
</div>
</div>
</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">Submit</button>
</div>
</form>
</div>
</div>
</div>
<div class="modal" id="photoModal" tabindex="-1"
aria-labelledby="photoModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="photoModalLabel">Upload New
Photo</h1>
<button class="btn-close" type="button" data-bs-dismiss="modal"
aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="form-floating mb-3 required">
<input type="file" id="file-input">
</div>
<div id="result"></div>
<div class="box-2 img-result hide" id="img-result">
</div>
<!-- result of crop -->
<img class="cropped" src="" alt="">
<button type="button" id="photo-save">Save</button>
</div>
</div>
</div>
</div>
</div>
<script src="{{ url_for('static', filename='js/jquery-3.6.3.min.js') }}" charset="utf-8"></script>
<!-- Normalize CSS -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/normalize/5.0.0/normalize.min.css">
<!-- Cropper CSS -->
<link rel='stylesheet' href='https://cdnjs.cloudflare.com/ajax/libs/cropper/2.3.4/cropper.min.css'>
<!-- Cropper JS -->
<script src='https://cdnjs.cloudflare.com/ajax/libs/cropperjs/0.8.1/cropper.min.js'></script>
<script charset="utf-8">
$('#officerModal').on('show.bs.modal', function(event) {
var modal = $(this)
// Zero all fields
modal.find('#position').val('')
modal.find('#term_start').val('')
modal.find('#term_end').val('')
// Get related ID of the officer position
var button = $(event.relatedTarget)
var position,term_start,term_end
id = button.data('id')
user_id = button.data('user-id')
saveButton = document.getElementById("edit-save")
saveButton.dataset.id = id
editForm = document.getElementById("officer-form")
$.get(`/admin/officer/get/${id}`, (data) => {
console.log(data)
if (data.status == "error") {
// new officer, do nothing
editForm.action = "/admin/officer/new/" + user_id
} else {
editForm.action = "/admin/officer/update/" + user_id + "/" + id
position = data.position
start = new Date(data.term_start)
end = new Date(data.term_end)
var day = ("0" + start.getDate()).slice(-2);
var month = ("0" + (start.getMonth() + 1)).slice(-2);
term_start = start.getFullYear()+"-"+(month)+"-"+(day)
var day = ("0" + end.getDate()).slice(-2);
var month = ("0" + (end.getMonth() + 1)).slice(-2);
term_end = end.getFullYear()+"-"+(month)+"-"+(day)
}
modal.find('#position').val(position)
modal.find('#term_start').val(term_start)
modal.find('#term_end').val(term_end)
})
});
</script>
{% endblock %}

View file

@ -29,10 +29,14 @@
class="caret"></span></a>
<ul class="dropdown-menu">
{% if u.is_admin %}
<li class="dropdown-item">Demote Officer</li>
<li class="dropdown-item">Demote Officer</li>
{% else %}
<li class="dropdown-item">Promote Officer</li>
{% endif %}
<li class="dropdown-item"><a href="{{
url_for('admin.officer_positions',
user_id=u.id)}}">Manage Officer
Entries</a></li>
<li class="dropdown-item">View Event Checkins</li>
<li class="dropdown-item">Delete Member</li>
</ul>
@ -40,7 +44,7 @@
</td>
</tr>
{% endfor %}
</tbody>
</tbody>
</table>
{% endblock app_content %}

1
acmsite/tmp/members.csv Normal file
View file

@ -0,0 +1 @@
Salter,Cara,csalter2@wpi.edu
1 Salter Cara csalter2@wpi.edu

View file

@ -27,3 +27,4 @@ urllib3==2.2.1
Werkzeug==2.3.7
WTForms==3.1.2
flask_wtf
pillow