attachments: added attachments system
Still need some work on it…
This commit is contained in:
parent
47efd6689b
commit
56a584c535
|
@ -28,8 +28,8 @@ app.url_map.converters['topicpage'] = TopicPageConverter
|
|||
# Register routes
|
||||
from app import routes
|
||||
|
||||
# Register utils
|
||||
from app import utils
|
||||
# Register filters
|
||||
from app.utils import filters
|
||||
|
||||
# Register processors
|
||||
from app import processors
|
||||
|
|
|
@ -11,7 +11,7 @@
|
|||
shoutbox-kick shoutbox-ban
|
||||
unlimited-pms footer-statistics community-login
|
||||
access-admin-panel edit-account delete-account edit-trophies
|
||||
delete_notification
|
||||
delete_notification no-upload-limits
|
||||
-
|
||||
name: Modérateur
|
||||
css: "color: green;"
|
||||
|
@ -21,7 +21,7 @@
|
|||
move-public-content extract-posts
|
||||
delete-notes delete-tests
|
||||
shoutbox-kick shoutbox-ban
|
||||
unlimited-pms
|
||||
unlimited-pms no-upload-limits
|
||||
-
|
||||
name: Développeur
|
||||
css: "color: #4169e1;"
|
||||
|
@ -31,7 +31,7 @@
|
|||
scheduled-posting
|
||||
edit-static-content
|
||||
unlimited-pms footer-statistics community-login
|
||||
access-admin-panel
|
||||
access-admin-panel no-upload-limits
|
||||
-
|
||||
name: Rédacteur
|
||||
css: "color: blue;"
|
||||
|
@ -41,6 +41,7 @@
|
|||
upload-shared-files delete-shared-files
|
||||
scheduled-posting
|
||||
showcase-content edit-static-content
|
||||
no-upload-limits
|
||||
-
|
||||
name: Responsable communauté
|
||||
css: "color: DarkOrange;"
|
||||
|
|
|
@ -1,23 +1,25 @@
|
|||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, FormField, SubmitField, TextAreaField
|
||||
from wtforms.validators import DataRequired, Length
|
||||
from wtforms import StringField, FormField, SubmitField, TextAreaField, \
|
||||
MultipleFileField
|
||||
from wtforms.validators import DataRequired, Length, Optional
|
||||
import app.utils.validators as vd
|
||||
|
||||
class TopicCreationForm(FlaskForm):
|
||||
title = StringField('Nom du sujet',
|
||||
validators=[DataRequired(), Length(min=3, max=32)])
|
||||
message = TextAreaField('Message principal', validators=[DataRequired()])
|
||||
submit = SubmitField('Créer le sujet')
|
||||
|
||||
class AnonymousTopicCreationForm(TopicCreationForm):
|
||||
pseudo = StringField('Pseudo',
|
||||
validators=[DataRequired(), vd.name_valid, vd.name_available])
|
||||
|
||||
|
||||
class CommentForm(FlaskForm):
|
||||
message = TextAreaField('Commentaire', validators=[DataRequired()])
|
||||
message = TextAreaField('Message', validators=[DataRequired()])
|
||||
attachments = MultipleFileField('Pièces-jointes',
|
||||
validators=[vd.file.optional, vd.file.count, vd.file.extension,
|
||||
vd.file.size])
|
||||
submit = SubmitField('Commenter')
|
||||
|
||||
class AnonymousCommentForm(CommentForm):
|
||||
pseudo = StringField('Pseudo',
|
||||
validators=[DataRequired(), vd.name_valid, vd.name_available])
|
||||
|
||||
|
||||
class TopicCreationForm(CommentForm):
|
||||
title = StringField('Nom du sujet',
|
||||
validators=[DataRequired(), Length(min=3, max=32)])
|
||||
submit = SubmitField('Créer le sujet')
|
||||
|
||||
class AnonymousTopicCreationForm(TopicCreationForm, AnonymousCommentForm):
|
||||
pass
|
||||
|
|
|
@ -1,5 +1,8 @@
|
|||
from werkzeug.utils import secure_filename
|
||||
from sqlalchemy.orm import backref
|
||||
from app import db
|
||||
from hashlib import sha256
|
||||
from app.utils.filesize import filesize
|
||||
from config import V5Config
|
||||
import os
|
||||
|
||||
class Attachment(db.Model):
|
||||
|
@ -9,24 +12,39 @@ class Attachment(db.Model):
|
|||
# Original name of the file
|
||||
name = db.Column(db.Unicode(64))
|
||||
|
||||
# Hash of the value
|
||||
hashed = db.Column(db.Unicode(64))
|
||||
|
||||
# The comment linked with
|
||||
comment_id = db.Column(db.Integer, db.ForeignKey('comment.id'), nullable=False)
|
||||
comment = db.relationship('Comment', backref=backref('attachments', lazy='dynamic'))
|
||||
comment = db.relationship('Comment', backref=backref('attachments'))
|
||||
|
||||
# The size of the file
|
||||
size = db.Column(db.Integer)
|
||||
|
||||
# Storage file path
|
||||
@property
|
||||
def path(self):
|
||||
return os.path.join(V5Config.DATA_FOLDER, "attachments",
|
||||
f"{self.id:05}", self.name)
|
||||
|
||||
@property
|
||||
def url(self):
|
||||
return f"/fichiers/{self.id:05}/{self.name}"
|
||||
|
||||
|
||||
def __init__(self, file, comment):
|
||||
self.name = file.filename
|
||||
self.size = os.stat(file).st_size
|
||||
self.hashed = self.hash_file(file)
|
||||
self.name = secure_filename(file.filename)
|
||||
self.size = filesize(file)
|
||||
self.comment = comment
|
||||
|
||||
def hash_file(file):
|
||||
with open(file,"rb") as f:
|
||||
bytes = f.read() # read entire file as bytes
|
||||
hashed = sha256(bytes).hexdigest()
|
||||
return hashed
|
||||
def set_file(self, file):
|
||||
os.mkdir(os.path.dirname(self.path))
|
||||
file.save(self.path)
|
||||
|
||||
def edit_file(self, file):
|
||||
file.name = secure_filename(file.filename)
|
||||
self.set_file(file)
|
||||
|
||||
def delete_file(self):
|
||||
try:
|
||||
os.delete(self.path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
# Register routes here
|
||||
|
||||
from app.routes import index, search, users, tools
|
||||
from app.routes import index, search, users, tools, development
|
||||
from app.routes.account import login, account, notification
|
||||
from app.routes.admin import index, groups, account, trophies, forums
|
||||
from app.routes.admin import index, groups, account, trophies, forums, attachments
|
||||
from app.routes.forum import index, topic
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
from app import app
|
||||
from app.models.attachment import Attachment
|
||||
from app.utils.priv_required import priv_required
|
||||
from app.utils.render import render
|
||||
|
||||
# TODO: add pagination & moderation tools (deletion)
|
||||
|
||||
@app.route('/admin/fichiers', methods=['GET'])
|
||||
@priv_required('access-admin-panel')
|
||||
def adm_attachments():
|
||||
attachments = Attachment.query.all()
|
||||
|
||||
return render('admin/attachments.html', attachments=attachments)
|
|
@ -0,0 +1,25 @@
|
|||
from flask import send_file, redirect, url_for, abort
|
||||
from werkzeug.utils import secure_filename
|
||||
from app import app
|
||||
from config import V5Config
|
||||
import os
|
||||
|
||||
# These routes are used in development
|
||||
# In production, those files should be served by the web server (nginx)
|
||||
|
||||
@app.route('/avatar/<filename>')
|
||||
def avatar(filename):
|
||||
filename = secure_filename(filename) # No h4ckers allowed
|
||||
filepath = os.path.join(V5Config.DATA_FOLDER, "avatars", filename)
|
||||
if os.path.isfile(filepath):
|
||||
return send_file(filepath)
|
||||
return redirect(url_for('static', filename='images/default_avatar.png'))
|
||||
|
||||
@app.route('/fichiers/<path>/<name>')
|
||||
def attachment(path, name):
|
||||
file = os.path.join(V5Config.DATA_FOLDER, "attachments",
|
||||
secure_filename(path), secure_filename(name))
|
||||
if os.path.isfile(file):
|
||||
return send_file(file)
|
||||
else:
|
||||
abort(404)
|
|
@ -10,6 +10,7 @@ from app.models.topic import Topic
|
|||
from app.models.thread import Thread
|
||||
from app.models.comment import Comment
|
||||
from app.models.users import Guest
|
||||
from app.models.attachment import Attachment
|
||||
|
||||
|
||||
@app.route('/forum/')
|
||||
|
@ -34,17 +35,18 @@ def forum_page(f):
|
|||
or ("/actus" not in f.url and not f.sub_forums)) and (
|
||||
V5Config.ENABLE_GUEST_POST or current_user.is_authenticated):
|
||||
|
||||
# First create the thread, then the comment, then the topic
|
||||
th = Thread()
|
||||
db.session.add(th)
|
||||
db.session.commit()
|
||||
|
||||
# Manage author
|
||||
if current_user.is_authenticated:
|
||||
author = current_user
|
||||
else:
|
||||
author = Guest(form.pseudo.data)
|
||||
db.session.add(author)
|
||||
|
||||
# First create the thread, then the comment, then the topic
|
||||
th = Thread()
|
||||
db.session.add(th)
|
||||
db.session.commit()
|
||||
|
||||
c = Comment(author, form.message.data, th)
|
||||
db.session.add(c)
|
||||
db.session.commit()
|
||||
|
@ -56,6 +58,17 @@ def forum_page(f):
|
|||
db.session.add(t)
|
||||
db.session.commit()
|
||||
|
||||
# Manage files
|
||||
attachments = []
|
||||
for file in form.attachments.data:
|
||||
if file.filename != "":
|
||||
a = Attachment(file, c)
|
||||
attachments.append((a, file))
|
||||
db.session.add(a)
|
||||
db.session.commit()
|
||||
for a, file in attachments:
|
||||
a.set_file(file)
|
||||
|
||||
# Update member's xp and trophies
|
||||
if current_user.is_authenticated:
|
||||
current_user.add_xp(2) # 2 points for a topic
|
||||
|
|
|
@ -10,6 +10,7 @@ from app.models.topic import Topic
|
|||
from app.models.thread import Thread
|
||||
from app.models.comment import Comment
|
||||
from app.models.users import Guest
|
||||
from app.models.attachment import Attachment
|
||||
|
||||
|
||||
@app.route('/forum/<forum:f>/<topicpage:page>', methods=['GET', 'POST'])
|
||||
|
@ -27,16 +28,29 @@ def forum_topic(f, page):
|
|||
|
||||
if form.validate_on_submit() and \
|
||||
(V5Config.ENABLE_GUEST_POST or current_user.is_authenticated):
|
||||
# Manage author
|
||||
if current_user.is_authenticated:
|
||||
author = current_user
|
||||
else:
|
||||
author = Guest(form.pseudo.data)
|
||||
db.session.add(author)
|
||||
|
||||
# Create comment
|
||||
c = Comment(author, form.message.data, t.thread)
|
||||
db.session.add(c)
|
||||
db.session.commit()
|
||||
|
||||
# Manage files
|
||||
attachments = []
|
||||
for file in form.attachments.data:
|
||||
if file.filename != "":
|
||||
a = Attachment(file, c)
|
||||
attachments.append((a, file))
|
||||
db.session.add(a)
|
||||
db.session.commit()
|
||||
for a, file in attachments:
|
||||
a.set_file(file)
|
||||
|
||||
# Update member's xp and trophies
|
||||
if current_user.is_authenticated:
|
||||
current_user.add_xp(1) # 1 point for a comment
|
||||
|
|
|
@ -21,11 +21,3 @@ def user(username):
|
|||
def user_by_id(user_id):
|
||||
member = Member.query.filter_by(id=user_id).first_or_404()
|
||||
return redirect(url_for('user', username=member.name))
|
||||
|
||||
@app.route('/avatar/<filename>')
|
||||
def avatar(filename):
|
||||
filename = secure_filename(filename) # No h4ckers allowed
|
||||
filepath = os.path.join(V5Config.DATA_FOLDER, "avatars", filename)
|
||||
if os.path.isfile(filepath):
|
||||
return send_file(filepath)
|
||||
return redirect(url_for('static', filename='images/default_avatar.png'))
|
||||
|
|
|
@ -0,0 +1,45 @@
|
|||
{% extends "base/base.html" %}
|
||||
|
||||
{% block title %}
|
||||
<a href="{{ url_for('adm') }}">Panneau d'administration</a> » <h1>Pièces jointes</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section>
|
||||
<p>Cette page présente une vue d'ensemble des pièces-jointes postées sur le site.</p>
|
||||
|
||||
<h2>Pièces jointes</h2>
|
||||
|
||||
<table style="width:95%; margin: auto;">
|
||||
<tr><th>ID</th><th>Nom</th><th>Auteur</th><th>Taille</th></tr>
|
||||
|
||||
{% for a in attachments %}
|
||||
<tr>
|
||||
<td>{{ a.id }}</td>
|
||||
<td><a href="{{ a.url }}">{{ a.name }}</a></td>
|
||||
<td><a href="{{ url_for('user', username=a.comment.author.name) }}">{{ a.comment.author.name }}</a></td>
|
||||
<td>{{ a.size }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
|
||||
<h2>Liste des groupes</h2>
|
||||
|
||||
<table style="width:90%; margin: auto;">
|
||||
<tr><th>Groupe</th><th>Membres</th><th>Privilèges</th></tr>
|
||||
|
||||
{% for group in groups %}
|
||||
<tr><td><span style="{{ group.css }}">{{ group.name }}</span></td><td>
|
||||
{% for user in group.members %}
|
||||
{{ user.name }}
|
||||
{% endfor %}
|
||||
</td><td>
|
||||
{% for priv in group.privs() %}
|
||||
<code>{{ priv }}</code>
|
||||
{{- ', ' if not loop.last }}
|
||||
{% endfor %}
|
||||
</td></tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</section>
|
||||
{% endblock %}
|
|
@ -8,9 +8,10 @@
|
|||
<section>
|
||||
<p>Pages générales du panneau d'administration :</p>
|
||||
<ul>
|
||||
<li><a href="{{ url_for('adm_groups') }}">Groupes et privilèges</a></li>
|
||||
<li><a href="{{ url_for('adm_groups') }}">Groupes et privilèges</a></li>
|
||||
<li><a href="{{ url_for('adm_trophies') }}">Titres et trophées</a></li>
|
||||
<li><a href="{{ url_for('adm_forums') }}">Arbre des forums</a></li>
|
||||
<li><a href="{{ url_for('adm_forums') }}">Arbre des forums</a></li>
|
||||
<li><a href="{{ url_for('adm_attachments') }}">Pièces-jointes</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
|
|
@ -69,6 +69,11 @@
|
|||
|
||||
{{ widget_editor.text_editor(form.message) }}
|
||||
|
||||
{{ form.attachments }}
|
||||
{% for error in form.attachments.errors %}
|
||||
<span class="msgerror">{{ error }}</span>
|
||||
{% endfor %}
|
||||
|
||||
<div>{{ form.submit(class_='bg-ok') }}</div>
|
||||
</form>
|
||||
</div>
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
{% import "widgets/editor.html" as widget_editor %}
|
||||
{% import "widgets/user.html" as widget_user %}
|
||||
{% import "widgets/pagination.html" as widget_pagination with context %}
|
||||
{% import "widgets/attachments.html" as widget_attachments %}
|
||||
|
||||
{% block title %}
|
||||
<a href='/forum'>Forum de Planète Casio</a> » <a href="{{ url_for('forum_page', f=t.forum) }}">{{ t.forum.name }}</a> » <h1>{{ t.title }}</h1>
|
||||
|
@ -34,10 +35,11 @@
|
|||
</div>
|
||||
<!--<hr>-->
|
||||
<p>{{ c.text }}</p>
|
||||
{{ widget_attachments.attachments(c) }}
|
||||
{% elif loop.index0 != 0 %}
|
||||
<div>Ce message est le top comment</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
|
@ -60,6 +62,11 @@
|
|||
|
||||
{{ widget_editor.text_editor(form.message, label=False) }}
|
||||
|
||||
{{ form.attachments }}
|
||||
{% for error in form.attachments.errors %}
|
||||
<span class="msgerror">{{ error }}</span>
|
||||
{% endfor %}
|
||||
|
||||
<div>{{ form.submit(class_='bg-ok') }}</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
{% macro attachments(comment) %}
|
||||
{% if comment.attachments %}
|
||||
<summary>Pièces-jointes</summary>
|
||||
<details>
|
||||
<table>
|
||||
<tr><th>Nom</th><th>Taille</th></tr>
|
||||
{% for a in comment.attachments %}
|
||||
<tr>
|
||||
<td><a href="{{ a.url }}">{{ a.name }}</a></td>
|
||||
<td>{{ a.size }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</details>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
|
@ -1,5 +0,0 @@
|
|||
# Register utils here
|
||||
|
||||
from app.utils import pluralize
|
||||
from app.utils import date
|
||||
from app.utils import is_title
|
|
@ -0,0 +1,9 @@
|
|||
import os
|
||||
from random import getrandbits
|
||||
|
||||
def filesize(file):
|
||||
"""Return the filesize. Save in /tmp and delete it when done"""
|
||||
file.seek(0, os.SEEK_END)
|
||||
size = file.tell()
|
||||
file.seek(0)
|
||||
return size
|
|
@ -0,0 +1,3 @@
|
|||
# Register filters here
|
||||
|
||||
from app.utils.filters import date, is_title, pluralize
|
|
@ -8,8 +8,12 @@ from app.utils.unicode_names import normalize
|
|||
from math import log
|
||||
from werkzeug.exceptions import NotFound
|
||||
import app.utils.ldap as ldap
|
||||
|
||||
from config import V5Config
|
||||
|
||||
from app.utils.validators.file import *
|
||||
|
||||
# TODO: clean this shit into split files
|
||||
|
||||
def name_valid(form, name):
|
||||
valid = valid_name(name.data)
|
||||
|
@ -35,7 +39,6 @@ def name_valid(form, name):
|
|||
err = ' '.join(msg.get(code, default) for code in valid)
|
||||
raise ValidationError(err)
|
||||
|
||||
|
||||
def name_available(form, name):
|
||||
# If the name is invalid, name_valid() will return a meaningful message
|
||||
try:
|
||||
|
@ -53,14 +56,11 @@ def name_available(form, name):
|
|||
if member is not None:
|
||||
raise ValidationError("Ce nom d'utilisateur est indisponible.")
|
||||
|
||||
|
||||
|
||||
def email(form, email):
|
||||
member = Member.query.filter_by(email=email.data).first()
|
||||
if member is not None:
|
||||
raise ValidationError('Adresse email déjà utilisée.')
|
||||
|
||||
|
||||
def password(form, password):
|
||||
# To avoid errors in forms where password is optionnal
|
||||
if len(password.data) == 0:
|
||||
|
@ -87,14 +87,12 @@ def password(form, password):
|
|||
if entropy(password.data) < 60:
|
||||
raise ValidationError("Mot de passe pas assez complexe")
|
||||
|
||||
|
||||
def avatar(form, avatar):
|
||||
try:
|
||||
Image.open(avatar.data)
|
||||
except IOError:
|
||||
raise ValidationError("Avatar invalide")
|
||||
|
||||
|
||||
def old_password(form, field):
|
||||
if field.data:
|
||||
if not form.old_password.data:
|
||||
|
@ -102,7 +100,6 @@ def old_password(form, field):
|
|||
if not current_user.check_password(form.old_password.data):
|
||||
raise ValidationError('Mot de passe actuel erroné.')
|
||||
|
||||
|
||||
def id_exists(object):
|
||||
"""Check if an id exists in a table"""
|
||||
def _id_exists(form, id):
|
||||
|
@ -115,7 +112,6 @@ def id_exists(object):
|
|||
raise ValidationError('L\'id n\'existe pas dans la BDD')
|
||||
return _id_exists
|
||||
|
||||
|
||||
def css(form, css):
|
||||
"""Check if input is valid and sane CSS"""
|
||||
pass
|
|
@ -0,0 +1,51 @@
|
|||
from flask_login import current_user
|
||||
from wtforms.validators import ValidationError, StopValidation
|
||||
from werkzeug.utils import secure_filename
|
||||
from app.utils.filesize import filesize
|
||||
import re
|
||||
|
||||
def optional(form, files):
|
||||
if(len(files.data) == 0 or files.data[0].filename == ""):
|
||||
raise StopValidation()
|
||||
|
||||
def count(form, files):
|
||||
if current_user.is_authenticated:
|
||||
if current_user.priv("no-upload-limits"):
|
||||
return
|
||||
if len(files.data) > 100: # 100 files for a authenticated user
|
||||
raise ValidationError("100 fichiers maximum autorisés")
|
||||
else:
|
||||
if len(files.data) > 3:
|
||||
raise ValidationError("3 fichiers maximum autorisés")
|
||||
|
||||
def extension(form, files):
|
||||
valid_extensions = [
|
||||
"g[123][a-z]|cpa|c1a|fxi|cat|mcs|xcp|fls", # Casio files
|
||||
"png|jpg|jpeg|bmp|tiff|gif|xcf", # Images
|
||||
"[ch](pp|\+\+|xx)?|s|py|bide|lua|lc", # Source code
|
||||
"txt|md|tex|pdf|odt|ods|docx|xlsx", # Office files
|
||||
"zip|7z|tar|bz2?|t?gz|xz|zst", # Archives
|
||||
]
|
||||
r = re.compile("|".join(valid_extensions), re.IGNORECASE)
|
||||
errors = []
|
||||
|
||||
for f in files.data:
|
||||
name = secure_filename(f.filename)
|
||||
ext = name.split(".")[-1]
|
||||
if not r.fullmatch(ext):
|
||||
errors.append("." + ext)
|
||||
|
||||
if len(errors) > 0:
|
||||
raise ValidationError(f"Extension(s) invalide(s) ({', '.join(errors)})")
|
||||
|
||||
def size(form, files):
|
||||
"""There is no global limit to file sizes"""
|
||||
size = sum([filesize(f) for f in files.data])
|
||||
if current_user.is_authenticated:
|
||||
if current_user.priv("no-upload-limits"):
|
||||
return
|
||||
if size > 5e6: # 5 Mo per comment for an authenticated user
|
||||
raise ValidationError("Fichiers trop lourds (max 5 Mo)")
|
||||
else:
|
||||
if size > 500e3: # 500 ko per comment for a guest
|
||||
raise ValidationError("Fichiers trop lourds (max 500 ko)")
|
|
@ -0,0 +1,35 @@
|
|||
"""Added attachments
|
||||
|
||||
Revision ID: cd4868f312c5
|
||||
Revises: 001d2eaf0413
|
||||
Create Date: 2020-08-01 19:22:12.405038
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'cd4868f312c5'
|
||||
down_revision = '001d2eaf0413'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('attachment',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sa.Unicode(length=64), nullable=True),
|
||||
sa.Column('comment_id', sa.Integer(), nullable=False),
|
||||
sa.Column('size', sa.Integer(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['comment_id'], ['comment.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table('attachment')
|
||||
# ### end Alembic commands ###
|
Loading…
Reference in New Issue