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:
parent
06ef8f047a
commit
026523b26f
11 changed files with 431 additions and 10 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -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
|
||||
|
|
2
Makefile
2
Makefile
|
@ -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 ---"
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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()])
|
||||
|
|
|
@ -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)
|
||||
|
|
103
acmsite/templates/admin/officer_photo.html
Normal file
103
acmsite/templates/admin/officer_photo.html
Normal 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 %}
|
173
acmsite/templates/admin/officers.html
Normal file
173
acmsite/templates/admin/officers.html
Normal 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 %}
|
|
@ -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
1
acmsite/tmp/members.csv
Normal file
|
@ -0,0 +1 @@
|
|||
Salter,Cara,csalter2@wpi.edu
|
|
|
@ -27,3 +27,4 @@ urllib3==2.2.1
|
|||
Werkzeug==2.3.7
|
||||
WTForms==3.1.2
|
||||
flask_wtf
|
||||
pillow
|
||||
|
|
Loading…
Add table
Reference in a new issue