version CUD

This commit is contained in:
SvyatoslavArtymovych 2023-06-13 11:34:17 +03:00
parent 62c42b7144
commit c8b7878d61
19 changed files with 357 additions and 89 deletions

View File

@ -1,4 +1,5 @@
import os
import warnings
from flask import Flask, render_template
from flask_sqlalchemy import SQLAlchemy
@ -110,8 +111,6 @@ def create_app(environment="development"):
index_view=CustomAdminIndexView(),
)
import warnings
with warnings.catch_warnings():
warnings.filterwarnings("ignore", "Fields missing from ruleset", UserWarning)

View File

@ -0,0 +1,88 @@
from app import models as m
from app.logger import log
def recursive_copy_collection(
collection: m.Collection, parent_id: int, version_id: int
):
collection_copy = m.Collection(
label=collection.label,
about=collection.about,
is_root=collection.is_root,
is_leaf=collection.is_leaf,
position=collection.position,
parent_id=parent_id,
version_id=version_id,
copy_of=collection.id,
)
log(log.INFO, "Create copy of collection [%s]", collection)
collection_copy.save()
if collection.active_sections:
for section in collection.active_sections:
section: m.Section
section_copy = m.Section(
label=section.label,
collection_id=collection_copy.id,
user_id=section.user_id,
version_id=version_id,
position=section.position,
copy_of=section.id,
)
log(log.INFO, "Create copy of section [%s]", section)
section_copy.save()
interpretation: m.Interpretation = section.approved_interpretation
if not interpretation:
continue
interpretation_copy = m.Interpretation(
text=interpretation.text,
plain_text=interpretation.plain_text,
approved=interpretation.approved,
user_id=interpretation.user_id,
section_id=section_copy.id,
copy_of=interpretation.id,
)
log(log.INFO, "Create copy of interpretation [%s]", interpretation_copy)
interpretation_copy.save()
comments: list[m.Comment] = section.approved_comments
for comment in comments:
comment_copy = m.Comment(
text=comment.text,
approved=comment.approved,
edited=comment.edited,
user_id=comment.user_id,
interpretation_id=interpretation_copy.id,
copy_of=comment.id,
)
log(log.INFO, "Create copy of comment [%s]", comment)
comment_copy.save()
elif collection.active_children:
for child in collection.active_children:
recursive_copy_collection(child, collection_copy.id, version_id)
def create_new_version(book: m.Book, semver: str):
book_active_version: m.BookVersion = book.active_version
book_root_collection: m.Collection = book_active_version.root_collection
version: m.BookVersion = m.BookVersion(
semver=semver, derivative_id=book.active_version.id, book_id=book.id
)
log(log.INFO, "Create new version for book [%s]", book)
version.save()
root_collection = m.Collection(
label="Root Collection",
version_id=version.id,
is_root=True,
copy_of=book_root_collection.id,
).save()
for collection in book_root_collection.active_children:
recursive_copy_collection(collection, root_collection.id, version.id)
return version

View File

@ -18,4 +18,4 @@ from .comment import CreateCommentForm
from .vote import VoteForm
from .comment import CreateCommentForm, DeleteCommentForm, EditCommentForm
from .permission import EditPermissionForm
from .version import EditVersionForm, DeleteVersionForm
from .version import EditVersionForm, DeleteVersionForm, CreateVersionForm

View File

@ -2,8 +2,6 @@ from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField, IntegerField, ValidationError
from wtforms.validators import DataRequired
from app.controllers import clean_html
from app.logger import log
from app import models as m, db
@ -31,3 +29,8 @@ class EditVersionForm(BaseVersionForm):
class DeleteVersionForm(BaseVersionForm):
submit = SubmitField("Delete")
class CreateVersionForm(FlaskForm):
semver = StringField("Semver", validators=[DataRequired()])
submit = SubmitField("Delete")

View File

@ -12,6 +12,7 @@ class Collection(BaseModel):
is_root = db.Column(db.Boolean, default=False)
is_leaf = db.Column(db.Boolean, default=False)
position = db.Column(db.Integer, default=-1, nullable=True)
copy_of = db.Column(db.Integer, default=0, nullable=True)
# Foreign keys
version_id = db.Column(db.ForeignKey("book_versions.id"))

View File

@ -12,6 +12,7 @@ class Comment(BaseModel):
text = db.Column(db.Text, unique=False, nullable=False)
approved = db.Column(db.Boolean, default=False)
edited = db.Column(db.Boolean, default=False)
copy_of = db.Column(db.Integer, default=0, nullable=True)
# Foreign keys
user_id = db.Column(db.ForeignKey("users.id"))

View File

@ -10,6 +10,7 @@ class Interpretation(BaseModel):
text = db.Column(db.Text, unique=False, nullable=False)
plain_text = db.Column(db.Text, unique=False)
approved = db.Column(db.Boolean, default=False)
copy_of = db.Column(db.Integer, default=0, nullable=True)
# Foreign keys
user_id = db.Column(db.ForeignKey("users.id"))

View File

@ -13,12 +13,13 @@ class Section(BaseModel):
__tablename__ = "sections"
label = db.Column(db.String(256), unique=False, nullable=False)
position = db.Column(db.Integer, default=-1, nullable=True)
copy_of = db.Column(db.Integer, default=0, nullable=True)
# Foreign keys
collection_id = db.Column(db.ForeignKey("collections.id"))
user_id = db.Column(db.ForeignKey("users.id"))
version_id = db.Column(db.ForeignKey("book_versions.id"))
position = db.Column(db.Integer, default=-1, nullable=True)
# Relationships
collection = db.relationship("Collection", viewonly=True)

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,24 @@
<!-- Add contributor modal -->
<!-- prettier-ignore-->
<div id="edit-version-label-modal" tabindex="-1" aria-hidden="true" class="fixed top-0 left-0 right-0 z-[150] hidden w-full p-4 overflow-x-hidden overflow-y-auto md:inset-0 h-[calc(100%-1rem)] max-h-full">
<div class="relative w-full max-w-2xl max-h-full">
<!-- Modal content -->
<form action="{{ url_for('book.edit_version', book_id=book.id) }}" method="post" class="relative bg-white rounded-lg shadow dark:bg-gray-700">
{{ form_hidden_tag() }}
<!-- Modal header -->
<input type="hidden" name="version_id" id="version_id" value="0" class="version-id-input"/>
<div class="p-6 space-y-6">
<div>
<label for="semver-input" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Name</label>
<input type="text" name="semver" id="semver-input" class="version-semver-input bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" placeholder="1.0.0" required>
</div>
</div>
<!-- Modal footer -->
<div class="flex items-center p-6 space-x-2 border-t border-gray-200 rounded-b dark:border-gray-600">
<button name="submit" type="submit" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">Save</button>
</div>
</form>
</div>
</div>

View File

@ -2,7 +2,7 @@
<div id="delete_version_modal" tabindex="-1" aria-hidden="true" class="fixed top-0 left-0 right-0 z-[150] hidden w-full p-4 overflow-x-hidden overflow-y-auto md:inset-0 h-[calc(100%-1rem)] max-h-full">
<div class="relative w-full max-w-2xl max-h-full">
<!-- Modal content -->
<form action="{{ url_for('book.delete_contributor', book_id=book.id) }}" id="delete_version_modal_form" method="post" class="relative bg-white rounded-lg shadow dark:bg-gray-700">
<form action="{{ url_for('book.delete_version', book_id=book.id) }}" id="delete_version_modal_form" method="post" class="relative bg-white rounded-lg shadow dark:bg-gray-700">
{{ form_hidden_tag() }}
<!-- Modal header -->
<input type="hidden" name="version_id" id="version_id" value="0" class="delete-version-id-input"/>

View File

@ -1,13 +1,11 @@
<!-- Add contributor modal -->
<!-- prettier-ignore-->
<div id="edit-version-label-modal" tabindex="-1" aria-hidden="true" class="fixed top-0 left-0 right-0 z-[150] hidden w-full p-4 overflow-x-hidden overflow-y-auto md:inset-0 h-[calc(100%-1rem)] max-h-full">
<div id="add-version-modal" tabindex="-1" aria-hidden="true" class="fixed top-0 left-0 right-0 z-[150] hidden w-full p-4 overflow-x-hidden overflow-y-auto md:inset-0 h-[calc(100%-1rem)] max-h-full">
<div class="relative w-full max-w-2xl max-h-full">
<!-- Modal content -->
<form action="{{ url_for('book.edit_version', book_id=book.id) }}" method="post" class="relative bg-white rounded-lg shadow dark:bg-gray-700">
<form action="{{ url_for('book.create_version', book_id=book.id) }}" method="post" class="relative bg-white rounded-lg shadow dark:bg-gray-700">
{{ form_hidden_tag() }}
<!-- Modal header -->
<input type="hidden" name="version_id" id="version_id" value="0" class="version-id-input"/>
<div class="p-6 space-y-6">
<div>
<label for="semver-input" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Name</label>

View File

@ -12,6 +12,7 @@
{% include 'book/modals/delete_contributor_modal.html' %}
{% include 'book/modals/edit_version_label_modal.html' %}
{% include 'book/modals/delete_version_modal.html' %}
{% include 'book/modals/edit_version_label_modal.html' %}
<!-- Hide right_sidebar -->
<!-- prettier-ignore -->
@ -188,7 +189,7 @@
</tr>
</thead>
<tbody>
{% for version in book.versions %}
{% for version in book.versions if not version.is_deleted and not version.is_active %}
<tr class="bg-white border-b dark:bg-gray-900 dark:border-gray-700">
<td class="px-6 truncate max-w-[280]">{{ version.semver }}</td>
<td class="px-6"> {{ version.created_at }} </td>
@ -198,7 +199,7 @@
</button>
</td>
<td class="px-4 py-4">
<button type="button" data-modal-target="delete_version_modal" data-modal-toggle="delete_version_modal" class="delete-version-btn text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:ring-red-300 font-sm rounded-lg text-sm px-5 py-1.5 dark:bg-red-600 dark:hover:bg-red-700 focus:outline-none dark:focus:ring-red-800">
<button type="button" data-version-id="{{ version.id }}" data-modal-target="delete_version_modal" data-modal-toggle="delete_version_modal" class="delete-version-btn text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:ring-red-300 font-sm rounded-lg text-sm px-5 py-1.5 dark:bg-red-600 dark:hover:bg-red-700 focus:outline-none dark:focus:ring-red-800">
Delete
</button>
</td>
@ -207,7 +208,7 @@
</tbody>
</table>
</div>
<button type="button" data-modal-target="add-version-modal" data-modal-toggle="add-versions-modal" class="add-versions-btn text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-4 py-2.5 text-center inline-flex items-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
<button type="button" data-modal-target="add-version-modal" data-modal-toggle="add-version-modal" class="add-versions-btn text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-4 py-2.5 text-center inline-flex items-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6 mr-1"> <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /> </svg>
New Version
</button>

View File

@ -1,50 +1,38 @@
from flask import render_template, flash, redirect, url_for, request
from flask import flash, redirect, url_for
from flask_login import login_required, current_user
from sqlalchemy import and_, or_
from app.controllers import (
create_pagination,
register_book_verify_route,
)
from app.controllers.tags import (
set_book_tags,
)
from app.controllers.delete_nested_book_entities import (
delete_nested_book_entities,
)
from app.controllers.create_access_groups import (
create_editor_group,
create_moderator_group,
)
from app.controllers.require_permission import require_permission
from app import models as m, db, forms as f
from app.controllers.version import create_new_version
from app.controllers.delete_nested_book_entities import delete_nested_version_entities
from app.logger import log
from .bp import bp
# @bp.route("/all", methods=["GET"])
# def get_all():
# log(log.INFO, "Create query for books")
# books: m.Book = m.Book.query.filter(m.Book.is_deleted is not False).order_by(
# m.Book.id
# )
# log(log.INFO, "Create pagination for books")
# pagination = create_pagination(total=books.count())
# log(log.INFO, "Returning data for front end")
# return render_template(
# "book/all.html",
# books=books.paginate(page=pagination.page, per_page=pagination.per_page),
# page=pagination,
# all_books=True,
# )
@bp.route("/create_version", methods=["POST"])
@bp.route("/<int:book_id>/create_version", methods=["POST"])
@login_required
def create_version():
raise NotImplementedError
def create_version(book_id):
form: f.CreateVersionForm = f.CreateVersionForm()
redirect_url = url_for("book.settings", selected_tab="versions", book_id=book_id)
if form.validate_on_submit():
book = db.session.get(m.Book, book_id)
if book.user_id != current_user.id:
flash("You are not owner of this book", "warning")
return redirect(redirect_url)
create_new_version(book, form.semver.data)
flash("Success!", "success")
return redirect(redirect_url)
else:
log(log.ERROR, "Create version errors: [%s]", form.errors)
for field, errors in form.errors.items():
field_label = form._fields[field].label.text
for error in errors:
flash(error.replace("Field", field_label), "danger")
return redirect(redirect_url)
@bp.route("/<int:book_id>/edit_version", methods=["POST"])
@ -75,7 +63,7 @@ def edit_version(book_id: int):
flash("Success!", "success")
return redirect(redirect_url)
else:
log(log.ERROR, "Section edit errors: [%s]", form.errors)
log(log.ERROR, "Edit version errors: [%s]", form.errors)
for field, errors in form.errors.items():
field_label = form._fields[field].label.text
for error in errors:
@ -105,14 +93,15 @@ def delete_version(book_id: int):
flash("You cant delete active version", "warning")
return redirect(redirect_url)
# TODO delete nested items
# log(log.INFO, "Edit version [%s]", version)
# version.save()
version.is_deleted = True
delete_nested_version_entities(version)
log(log.INFO, "Delete version [%s]", version)
version.save()
flash("Success!", "success")
return redirect(redirect_url)
else:
log(log.ERROR, "Section edit errors: [%s]", form.errors)
log(log.ERROR, "Delete version errors: [%s]", form.errors)
for field, errors in form.errors.items():
field_label = form._fields[field].label.text
for error in errors:

View File

@ -10,25 +10,27 @@ import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '03e8384a23e1'
down_revision = 'a41f004cad1a'
revision = "03e8384a23e1"
down_revision = "a41f004cad1a"
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('book_versions', schema=None) as batch_op:
batch_op.add_column(sa.Column('is_active', sa.Boolean(), nullable=True))
batch_op.drop_column('exported')
with op.batch_alter_table("book_versions", schema=None) as batch_op:
batch_op.add_column(sa.Column("is_active", sa.Boolean(), nullable=True))
batch_op.drop_column("exported")
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('book_versions', schema=None) as batch_op:
batch_op.add_column(sa.Column('exported', sa.BOOLEAN(), autoincrement=False, nullable=True))
batch_op.drop_column('is_active')
with op.batch_alter_table("book_versions", schema=None) as batch_op:
batch_op.add_column(
sa.Column("exported", sa.BOOLEAN(), autoincrement=False, nullable=True)
)
batch_op.drop_column("is_active")
# ### end Alembic commands ###

View File

@ -0,0 +1,50 @@
"""copy of fields
Revision ID: ef2254f9bc92
Revises: 03e8384a23e1
Create Date: 2023-06-13 09:43:26.575245
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "ef2254f9bc92"
down_revision = "03e8384a23e1"
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("collections", schema=None) as batch_op:
batch_op.add_column(sa.Column("copy_of", sa.Integer(), nullable=True))
with op.batch_alter_table("comments", schema=None) as batch_op:
batch_op.add_column(sa.Column("copy_of", sa.Integer(), nullable=True))
with op.batch_alter_table("interpretations", schema=None) as batch_op:
batch_op.add_column(sa.Column("copy_of", sa.Integer(), nullable=True))
with op.batch_alter_table("sections", schema=None) as batch_op:
batch_op.add_column(sa.Column("copy_of", sa.Integer(), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("sections", schema=None) as batch_op:
batch_op.drop_column("copy_of")
with op.batch_alter_table("interpretations", schema=None) as batch_op:
batch_op.drop_column("copy_of")
with op.batch_alter_table("comments", schema=None) as batch_op:
batch_op.drop_column("copy_of")
with op.batch_alter_table("collections", schema=None) as batch_op:
batch_op.drop_column("copy_of")
# ### end Alembic commands ###

View File

@ -6,9 +6,10 @@ export function initDeleteVersion() {
if (versionIdInput) {
const deleteBtns = document.querySelectorAll('.delete-version-btn');
deleteBtns.forEach(el => {
const versionId = el.getAttribute('data-version-id');
versionIdInput.value = versionId;
el.addEventListener('click', () => {
const versionId = el.getAttribute('data-version-id');
versionIdInput.value = versionId;
});
});
}
}

View File

@ -8,11 +8,13 @@ export function initEditVersion() {
if (versionIdInput && versionSemverInput) {
const editBtns = document.querySelectorAll('.edit-version-label-btns');
editBtns.forEach(el => {
const versionId = el.getAttribute('data-version-id');
const versionSemver = el.getAttribute('data-version-semver');
el.addEventListener('click', () => {
const versionId = el.getAttribute('data-version-id');
const versionSemver = el.getAttribute('data-version-semver');
versionIdInput.value = versionId;
versionSemverInput.value = versionSemver;
versionIdInput.value = versionId;
versionSemverInput.value = versionSemver;
});
});
}
}

View File

@ -1,12 +1,12 @@
from flask import current_app as Response
from flask.testing import FlaskClient, FlaskCliRunner
from flask.testing import FlaskClient
from app import models as m, db
from app.controllers.create_access_groups import create_moderator_group
from tests.utils import (
login,
logout,
create_book,
check_if_nested_version_entities_is_deleted,
)
@ -128,17 +128,34 @@ def test_delete_version(client: FlaskClient):
assert response.status_code == 200
assert b"Invalid version id" in response.data
# TODO improve test to check if nested items are deleted
# response: Response = client.post(
# f"/book/{book.id}/delete_version",
# data=dict(
# version_id=book_2.versions[0].id,
# ),
# follow_redirects=True,
# )
response: Response = client.post(
f"/book/{book_2.id}/create_version",
data=dict(
semver="MyVer",
),
follow_redirects=True,
)
# assert response.status_code == 200
# assert b"Success" in response.data
assert response.status_code == 200
assert b"Success" in response.data
assert len(book_2.versions) == 2
new_version: m.BookVersion = book_2.versions[-1]
for collection in new_version.root_collection.active_children:
recursive_copy_collection(collection)
response: Response = client.post(
f"/book/{book_2.id}/delete_version",
data=dict(
version_id=new_version.id,
),
follow_redirects=True,
)
assert response.status_code == 200
assert b"Success" in response.data
check_if_nested_version_entities_is_deleted(new_version)
logout(client)
login(client, username="test_user")
@ -153,3 +170,89 @@ def test_delete_version(client: FlaskClient):
assert response.status_code == 200
assert b"You are not owner of this book" in response.data
def recursive_copy_collection(collection: m.Collection):
collection: m.Collection
assert collection.copy_of
copy_of: m.Collection = db.session.get(m.Collection, collection.copy_of)
assert collection.label == copy_of.label
assert collection.about == copy_of.about
assert collection.is_root == copy_of.is_root
assert collection.is_leaf == copy_of.is_leaf
assert collection.position == copy_of.position
if collection.active_sections:
for section in collection.active_sections:
section: m.Section
copy_of = db.session.get(m.Section, section.copy_of)
assert section.label == copy_of.label
assert section.position == copy_of.position
interpretations: list[m.Interpretation] = section.approved_interpretation
for interpretation in interpretations:
interpretation: m.Interpretation
assert interpretation.copy_of
copy_of: m.Interpretation = db.session.get(
m.Interpretation, interpretation.copy_of
)
assert interpretation.text == copy_of.text
assert interpretation.plain_text == copy_of.plain_text
assert interpretation.approved == copy_of.approved
comments: list[m.Comment] = section.approved_comments
for comment in comments:
comment: m.Comment
assert comment.copy_of
copy_of: m.Comment = db.session.get(m.Comment, comment.copy_of)
assert comment.text == copy_of.text
assert comment.approved == copy_of.approved
assert comment.edited == copy_of.edited
elif collection.active_children:
for child in collection.active_children:
recursive_copy_collection(child)
def test_create_version(client):
login(client)
book: m.Book = create_book(client)
book_2: m.Book = create_book(client)
logout(client)
login(client, "test_2")
response: Response = client.post(
f"/book/{book.id}/create_version",
data=dict(
semver="MyVer",
),
follow_redirects=True,
)
assert response.status_code == 200
assert b"You are not owner of this book" in response.data
logout(client)
login(client)
response: Response = client.post(
f"/book/{book.id}/create_version",
data=dict(
semver="MyVer",
),
follow_redirects=True,
)
assert response.status_code == 200
assert b"Success" in response.data
assert len(book.versions) == 2
new_version: m.BookVersion = book.versions[-1]
for collection in new_version.root_collection.active_children:
recursive_copy_collection(collection)