Merge pull request #69 from Simple2B/svyat/feat/flask_admin

Flask admin
This commit is contained in:
Svyatoslav Artymovych 2023-06-09 17:07:02 +03:00 committed by GitHub
commit 0d1c0142f8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 2236 additions and 1457 deletions

View File

@ -5,6 +5,7 @@ from flask_sqlalchemy import SQLAlchemy
from flask_login import LoginManager
from werkzeug.exceptions import HTTPException
from flask_migrate import Migrate
from flask_admin import Admin
from app.logger import log
@ -28,7 +29,7 @@ def create_app(environment="development"):
permissions_blueprint,
search_blueprint,
)
from app.models import User, AnonymousUser, Permission
from app import models as m
# Instantiate app.
app = Flask(__name__)
@ -60,11 +61,11 @@ def create_app(environment="development"):
# Set up flask login.
@login_manager.user_loader
def get_user(id):
return User.query.get(int(id))
return m.User.query.get(int(id))
login_manager.login_view = "auth.login"
login_manager.login_message_category = "info"
login_manager.anonymous_user = AnonymousUser
login_manager.anonymous_user = m.AnonymousUser
# Jinja globals
from app.controllers.jinja_globals import (
@ -75,8 +76,8 @@ def create_app(environment="development"):
has_permission,
)
app.jinja_env.globals["Access"] = Permission.Access
app.jinja_env.globals["EntityType"] = Permission.Entity
app.jinja_env.globals["Access"] = m.Permission.Access
app.jinja_env.globals["EntityType"] = m.Permission.Entity
app.jinja_env.globals["form_hidden_tag"] = form_hidden_tag
app.jinja_env.globals["display_inline_elements"] = display_inline_elements
@ -89,4 +90,49 @@ def create_app(environment="development"):
def handle_http_error(exc):
return render_template("error.html", error=exc), exc.code
# flask admin
from app.controllers.admin import (
CustomAdminIndexView,
UsersView,
BooksView,
CollectionsView,
SectionsView,
InterpretationView,
CommentView,
TagView,
BookContributorView,
)
app.config["FLASK_ADMIN_SWATCH"] = "Flatly"
admin = Admin(
app,
name="Open Law Admin",
template_mode="bootstrap3",
index_view=CustomAdminIndexView(),
)
for view in [
UsersView(m.User, db.session, name="User", endpoint="/user_"),
BooksView(m.Book, db.session, name="Book", endpoint="/book_"),
CollectionsView(
m.Collection, db.session, name="Collection", endpoint="/collection_"
),
SectionsView(m.Section, db.session, name="Section", endpoint="/section_"),
InterpretationView(
m.Interpretation,
db.session,
name="Interpretation",
endpoint="/interpretation_",
),
CommentView(m.Comment, db.session, name="Comment", endpoint="/comment_"),
TagView(m.Tag, db.session, name="Tag", endpoint="/tag_"),
BookContributorView(
m.BookContributor,
db.session,
name="BookContributor",
endpoint="/book_contributor_",
),
]:
admin.add_view(view)
return app

View File

@ -0,0 +1,11 @@
# flake8: noqa F401
from .custom_admin_index_view import CustomAdminIndexView
from .protected_model_view import ProtectedModelView
from .user import UsersView
from .book import BooksView
from .collection import CollectionsView
from .section import SectionsView
from .interpretation import InterpretationView
from .comment import CommentView
from .tag import TagView
from .book_contributors import BookContributorView

View File

@ -0,0 +1,50 @@
from flask_admin.base import expose
from flask_admin.helpers import get_redirect_target, flash_errors
from flask import redirect, flash
from flask_admin.babel import gettext
from app.controllers.delete_nested_book_entities import delete_nested_book_entities
from .protected_model_view import ProtectedModelView
class BooksView(ProtectedModelView):
column_list = ("id", "label", "about", "is_deleted", "owner", "created_at")
form_edit_rules = (
"label",
"about",
"is_deleted",
"created_at",
)
@expose("/delete/", methods=("POST",))
def delete_view(self):
return_url = get_redirect_target() or self.get_url(".index_view")
if not self.can_delete:
return redirect(return_url)
form = self.delete_form()
if self.validate_form(form):
id = form.id.data
model = self.get_one(id)
if model is None:
flash(gettext("Record does not exist."), "error")
return redirect(return_url)
model.is_deleted = True
delete_nested_book_entities(model)
model.save()
flash(
gettext(
"Book and nested entities were successfully deleted.",
),
"success",
)
return redirect(return_url)
else:
flash_errors(form, message="Failed to delete record. %(error)s")
return redirect(return_url)

View File

@ -0,0 +1,115 @@
from flask_admin.base import expose
from flask_admin.helpers import (
get_redirect_target,
flash_errors,
)
from flask import redirect, flash, request
from flask_admin.babel import gettext
from wtforms import SelectField
from flask_admin.contrib.sqla.fields import QuerySelectField
from flask_admin.form import form
from flask_admin.model.template import EndpointLinkRowAction
from .protected_model_view import ProtectedModelView
from app import models as m, forms as f
from app.controllers.contributor import (
add_contributor_to_book,
delete_contributor_from_book,
)
from app.controllers.permission import set_access_level
from app.logger import log
class BookContributorViewCreateForm(form.Form):
book = QuerySelectField("Book", get_label="label")
user = QuerySelectField("User", get_label="username")
role = SelectField(
"Role",
choices=[
(role.value, role.name)
for role in m.BookContributor.Roles
if role.value > 0
],
)
class BookContributorView(ProtectedModelView):
column_list = ("id", "created_at", "role", "user", "book")
can_create = True
can_edit = False
column_extra_row_actions = [ # Add a new action button
EndpointLinkRowAction("glyphicon glyphicon-list-alt", ".edit_access_level"),
]
@expose("/edit_access_level/<string:id>", methods=("GET", "POST"))
def edit_access_level(self, id):
model: m.BookContributor = self.get_one((id,))
user = model.user
book = model.book
form: f.EditPermissionForm = f.EditPermissionForm()
if form.validate_on_submit():
set_access_level(form, book)
return self.render(
"admin/contributor_access_level.html", user=user, book=book, id=id
)
def create_form(self):
form = BookContributorViewCreateForm(request.form)
form.book.query = m.Book.query.filter_by(is_deleted=False)
form.user.query = m.User.query
return form
@expose("/new/", methods=("GET", "POST"))
def create_view(self):
return_url = get_redirect_target() or self.get_url(".index_view")
if not self.can_delete:
return redirect(return_url)
form: BookContributorViewCreateForm = self.create_form()
if (
form.user.data
and form.book.data
and form.book.data.user_id == form.user.data.id
):
flash("This user is owner of this book", "danger")
elif self.validate_form(form):
add_contributor_to_book(form, form.book.data.id, user_id=form.user.data.id)
return self.render("admin/model/create.html", form=form)
@expose("/delete/", methods=("POST",))
def delete_view(self):
return_url = get_redirect_target() or self.get_url(".index_view")
if not self.can_delete:
return redirect(return_url)
form = self.delete_form()
if self.validate_form(form):
id = form.id.data
model = self.get_one(id)
if model is None:
flash(gettext("Record does not exist."), "error")
return redirect(return_url)
try:
delete_contributor_from_book(form, model.book_id, user_id=model.user_id)
except Exception as e:
log(
log.EXCEPTION,
"AdminPanel delete contributor unexpected error [%s]",
str(e),
)
return redirect(return_url)
else:
flash_errors(form, message="Failed to delete record. %(error)s")
return redirect(return_url)

View File

@ -0,0 +1,62 @@
from flask_admin.base import expose
from flask_admin.helpers import get_redirect_target, flash_errors
from flask import redirect, flash
from flask_admin.babel import gettext
from app.controllers.delete_nested_book_entities import (
delete_nested_collection_entities,
)
from .protected_model_view import ProtectedModelView
class CollectionsView(ProtectedModelView):
column_list = (
"id",
"label",
"about",
"is_root",
"is_leaf",
"position",
"is_deleted",
"created_at",
)
form_edit_rules = (
"label",
"about",
"position",
"is_deleted",
"created_at",
)
@expose("/delete/", methods=("POST",))
def delete_view(self):
return_url = get_redirect_target() or self.get_url(".index_view")
if not self.can_delete:
return redirect(return_url)
form = self.delete_form()
if self.validate_form(form):
id = form.id.data
model = self.get_one(id)
if model is None:
flash(gettext("Record does not exist."), "error")
return redirect(return_url)
model.is_deleted = True
delete_nested_collection_entities(model)
model.save()
flash(
gettext(
"Collection and nested entities were successfully deleted.",
),
"success",
)
return redirect(return_url)
else:
flash_errors(form, message="Failed to delete record. %(error)s")
return redirect(return_url)

View File

@ -0,0 +1,61 @@
from flask_admin.base import expose
from flask_admin.helpers import get_redirect_target, flash_errors
from flask import redirect, flash
from flask_admin.babel import gettext
from app.controllers.delete_nested_book_entities import (
delete_nested_comment_entities,
)
from .protected_model_view import ProtectedModelView
class CommentView(ProtectedModelView):
column_list = (
"id",
"text",
"approved",
"edited",
"is_deleted",
"user",
"created_at",
)
form_edit_rules = (
"text",
"approved",
"edited",
"is_deleted",
"created_at",
)
@expose("/delete/", methods=("POST",))
def delete_view(self):
return_url = get_redirect_target() or self.get_url(".index_view")
if not self.can_delete:
return redirect(return_url)
form = self.delete_form()
if self.validate_form(form):
id = form.id.data
model = self.get_one(id)
if model is None:
flash(gettext("Record does not exist."), "error")
return redirect(return_url)
model.is_deleted = True
delete_nested_comment_entities(model)
model.save()
flash(
gettext(
"Section and nested entities were successfully deleted.",
),
"success",
)
return redirect(return_url)
else:
flash_errors(form, message="Failed to delete record. %(error)s")
return redirect(return_url)

View File

@ -0,0 +1,16 @@
from flask_admin import AdminIndexView
from flask_login import current_user
from flask import redirect, url_for
class CustomAdminIndexView(AdminIndexView):
def is_accessible(self):
return current_user.is_super_user
def is_visible(self):
# This view won't appear in the menu structure
return False
def inaccessible_callback(self, name, **kwargs):
# redirect to login page if user doesn't have access
return redirect(url_for("main.index"))

View File

@ -0,0 +1,60 @@
from flask_admin.base import expose
from flask_admin.helpers import get_redirect_target, flash_errors
from flask import redirect, flash
from flask_admin.babel import gettext
from app.controllers.delete_nested_book_entities import (
delete_nested_interpretation_entities,
)
from .protected_model_view import ProtectedModelView
class InterpretationView(ProtectedModelView):
column_list = (
"id",
"plain_text",
"approved",
"is_deleted",
"user",
"created_at",
)
form_edit_rules = (
"text",
"plain_text",
"approved",
"is_deleted",
"created_at",
)
@expose("/delete/", methods=("POST",))
def delete_view(self):
return_url = get_redirect_target() or self.get_url(".index_view")
if not self.can_delete:
return redirect(return_url)
form = self.delete_form()
if self.validate_form(form):
id = form.id.data
model = self.get_one(id)
if model is None:
flash(gettext("Record does not exist."), "error")
return redirect(return_url)
model.is_deleted = True
delete_nested_interpretation_entities(model)
model.save()
flash(
gettext(
"Section and nested entities were successfully deleted.",
),
"success",
)
return redirect(return_url)
else:
flash_errors(form, message="Failed to delete record. %(error)s")
return redirect(return_url)

View File

@ -0,0 +1,11 @@
from flask_admin.contrib.sqla import ModelView
from flask_login import current_user
class ProtectedModelView(ModelView):
action_disallowed_list = ["delete"]
column_default_sort = "id"
can_create = False
def is_accessible(self):
return current_user.is_super_user

View File

@ -0,0 +1,59 @@
from flask_admin.base import expose
from flask_admin.helpers import get_redirect_target, flash_errors
from flask import redirect, flash
from flask_admin.babel import gettext
from app.controllers.delete_nested_book_entities import (
delete_nested_section_entities,
)
from .protected_model_view import ProtectedModelView
class SectionsView(ProtectedModelView):
column_list = (
"id",
"label",
"position",
"is_deleted",
"user",
"created_at",
)
form_edit_rules = (
"label",
"position",
"is_deleted",
"created_at",
)
@expose("/delete/", methods=("POST",))
def delete_view(self):
return_url = get_redirect_target() or self.get_url(".index_view")
if not self.can_delete:
return redirect(return_url)
form = self.delete_form()
if self.validate_form(form):
id = form.id.data
model = self.get_one(id)
if model is None:
flash(gettext("Record does not exist."), "error")
return redirect(return_url)
model.is_deleted = True
delete_nested_section_entities(model)
model.save()
flash(
gettext(
"Section and nested entities were successfully deleted.",
),
"success",
)
return redirect(return_url)
else:
flash_errors(form, message="Failed to delete record. %(error)s")
return redirect(return_url)

View File

@ -0,0 +1,55 @@
from flask_admin.base import expose
from flask_admin.helpers import get_redirect_target, flash_errors
from flask import redirect, flash
from flask_admin.babel import gettext
from app.controllers.delete_nested_book_entities import (
delete_nested_comment_entities,
)
from .protected_model_view import ProtectedModelView
class TagView(ProtectedModelView):
column_list = (
"id",
"name",
"created_at",
)
form_edit_rules = (
"id",
"name",
"created_at",
)
@expose("/delete/", methods=("POST",))
def delete_view(self):
return_url = get_redirect_target() or self.get_url(".index_view")
if not self.can_delete:
return redirect(return_url)
form = self.delete_form()
if self.validate_form(form):
id = form.id.data
model = self.get_one(id)
if model is None:
flash(gettext("Record does not exist."), "error")
return redirect(return_url)
model.is_deleted = True
delete_nested_comment_entities(model)
model.save()
flash(
gettext(
"Section and nested entities were successfully deleted.",
),
"success",
)
return redirect(return_url)
else:
flash_errors(form, message="Failed to delete record. %(error)s")
return redirect(return_url)

View File

@ -0,0 +1,11 @@
from .protected_model_view import ProtectedModelView
class UsersView(ProtectedModelView):
column_list = (
"id",
"username",
"is_activated",
"wallet_id",
"is_super_user",
)

View File

@ -0,0 +1,96 @@
from flask import flash, redirect, url_for
from app import forms as f, models as m, db
from app.logger import log
def add_contributor_to_book(
form: f.AddContributorForm,
book_id: int,
selected_tab: str = "",
user_id: int = None,
):
if not user_id:
user_id = form.user_id.data
book_contributor = m.BookContributor.query.filter_by(
user_id=user_id, book_id=book_id
).first()
if book_contributor:
log(log.INFO, "Contributor: [%s] already exists", book_contributor)
flash("Already exists!", "danger")
return redirect(
url_for("book.settings", selected_tab=selected_tab, book_id=book_id)
)
role = m.BookContributor.Roles(int(form.role.data))
contributor = m.BookContributor(user_id=user_id, book_id=book_id, role=role)
log(log.INFO, "New contributor [%s]", contributor)
contributor.save()
groups = (
db.session.query(m.AccessGroup)
.filter(
m.BookAccessGroups.book_id == book_id,
m.AccessGroup.id == m.BookAccessGroups.access_group_id,
m.AccessGroup.name == role.name.lower(),
)
.all()
)
for group in groups:
m.UserAccessGroups(user_id=user_id, access_group_id=group.id).save()
flash("Contributor was added!", "success")
return redirect(
url_for("book.settings", selected_tab=selected_tab, book_id=book_id)
)
def delete_contributor_from_book(
form: f.DeleteContributorForm,
book_id: int,
selected_tab: str = "",
user_id: int = None,
):
if not user_id:
user_id = form.user_id.data
book_contributor = m.BookContributor.query.filter_by(
user_id=user_id, book_id=book_id
).first()
if not book_contributor:
log(
log.INFO,
"BookContributor does not exists user: [%s], book: [%s]",
user_id,
book_id,
)
flash("Does not exists!", "success")
return redirect(
url_for("book.settings", selected_tab=selected_tab, book_id=book_id)
)
book: m.Book = db.session.get(m.Book, book_id)
user: m.User = db.session.get(m.User, user_id)
if book:
for access_group in book.access_groups:
access_group: m.AccessGroup
if user in access_group.users:
log(
log.INFO,
"Delete user [%s] from AccessGroup [%s]",
user,
access_group,
)
relationships_to_delete = m.UserAccessGroups.query.filter_by(
user_id=user_id, access_group_id=access_group.id
).all()
for relationship in relationships_to_delete:
db.session.delete(relationship)
log(log.INFO, "Delete BookContributor [%s]", book_contributor)
db.session.delete(book_contributor)
db.session.commit()
flash("Success!", "success")
return redirect(
url_for("book.settings", selected_tab=selected_tab, book_id=book_id)
)

View File

@ -0,0 +1,89 @@
import json
from flask_login import current_user
from flask import flash, redirect, url_for
from app.logger import log
from app import models as m, db, forms as f
from app.controllers.create_access_groups import (
create_editor_group,
create_moderator_group,
)
def set_access_level(form: f.EditPermissionForm, book: m.Book):
user_id = form.user_id.data
contributor: m.BookContributor = m.BookContributor.query.filter_by(
user_id=user_id, book_id=book.id
).first()
if not contributor:
log(
log.INFO,
"User: [%s] is not contributor of book: [%s]",
current_user,
book,
)
flash("User are not contributor of this book!", "danger")
return redirect(url_for("book.my_library"))
user: m.User = contributor.user
users_access_groups: list[m.AccessGroup] = list(
set(book.list_access_groups).intersection(user.access_groups)
)
if len(users_access_groups) > 1:
log(
log.WARNING,
"User: [%s] has more than 1 access group in book [%s]",
user,
book,
)
for users_access in users_access_groups:
users_access: m.AccessGroup
users_access.users.remove(user)
permissions_json = json.loads(form.permissions.data)
book_ids = permissions_json.get("book", [])
for book_id in book_ids:
entire_boot_access_group = m.AccessGroup.query.filter_by(
book_id=book_id, name=contributor.role.name.lower()
).first()
m.UserAccessGroups(
user_id=user.id, access_group_id=entire_boot_access_group.id
).save(False)
db.session.commit()
flash("Success!", "success")
return redirect(url_for("book.settings", book_id=book.id))
new_access_group = None
match contributor.role:
case m.BookContributor.Roles.EDITOR:
new_access_group = create_editor_group(book.id)
case m.BookContributor.Roles.MODERATOR:
new_access_group = create_moderator_group(book.id)
case _:
log(
log.CRITICAL,
"Unknown contributor's [%s] role: [%s]",
contributor,
contributor.role,
)
flash("Unknown contributor's role", "danger")
return redirect(url_for("book.settings", book_id=book.id))
m.UserAccessGroups(user_id=user.id, access_group_id=new_access_group.id).save(False)
collection_ids = permissions_json.get("collection", [])
for collection_id in collection_ids:
m.CollectionAccessGroups(
collection_id=collection_id, access_group_id=new_access_group.id
).save(False)
section_ids = permissions_json.get("section", [])
for section_id in section_ids:
m.SectionAccessGroups(
section_id=section_id, access_group_id=new_access_group.id
).save(False)
db.session.commit()
flash("Success!", "success")
return redirect(url_for("book.settings", book_id=book.id))

View File

@ -11,7 +11,6 @@ class Comment(BaseModel):
id = db.Column(db.Integer, primary_key=True)
text = db.Column(db.Text, unique=False, nullable=False)
approved = db.Column(db.Boolean, default=False)
marked = db.Column(db.Boolean, default=False)
edited = db.Column(db.Boolean, default=False)
# Foreign keys

View File

@ -10,7 +10,6 @@ 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)
marked = db.Column(db.Boolean, default=False)
# Foreign keys
user_id = db.Column(db.ForeignKey("users.id"))

View File

@ -18,7 +18,6 @@ class Section(BaseModel):
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"))
selected_interpretation_id = db.Column(db.Integer, nullable=True)
position = db.Column(db.Integer, default=-1, nullable=True)
# Relationships

View File

@ -23,6 +23,7 @@ class User(BaseModel, UserMixin):
is_activated = db.Column(db.Boolean, default=False)
wallet_id = db.Column(db.String(64), nullable=True)
avatar_img = db.Column(db.Text, nullable=True)
is_super_user = db.Column(db.Boolean, default=False)
# Relationships
access_groups = db.relationship(

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,70 @@
{% extends 'admin/base.html' %}
{% block head_css %}
{{ super() }}
{% endblock %}
{% block body %}
<span class="hidden trigger-refreshAccessLevelTree"></span>
<div>
<div>Edit {{ user }} access level in book {{ book }}</div>
<form action="{{ url_for('.edit_access_level', id=id) }}" method="post">
{{ form_hidden_tag() }}
<input type="hidden" value="{{ book.id }}" name="book_id" id="permission_modal_book_id"/>
<input type="hidden" value="{{ user.id }}" name="user_id" id="permission_modal_user_id"/>
<input type="hidden" name="permissions" id="permissions_json"/>
<div>
<div class="checkbox-tree">
<ul>
<li>
<div>
<input
type="checkbox"
data-root="true"
data-access-to="book"
data-access-to-id="{{ book.id }}"
/>
<span>{{ book.label }}</span>
</div>
{%- for collection in book.last_version.children_collections recursive %}
<ul>
<li>
<div>
<input type="checkbox" data-access-to="collection" data-access-to-id="{{ collection.id }}"/>
<span>{{ collection.label }}</span>
</div>
{% if collection.active_children %}
{{ loop(collection.active_children)}}
{% else %}
{% for section in collection.sections %}
<ul>
<li>
<div>
<input type="checkbox" data-access-to="section" data-access-to-id="{{ section.id }}"/>
<span>{{ section.label }}</span>
</div>
</li>
</ul>
{% endfor %}
{% endif %}
</li>
</ul>
{%- endfor %}
</li>
</ul>
</div>
</div>
<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>
<script src="{{ url_for('static', filename='js/main.js') }}" type="text/javascript" defer></script>
{% endblock %}

View File

@ -41,47 +41,20 @@
</div>
{% if collection.active_children %}
{{ loop(collection.active_children)}}
{#
{% for sub_collection in collection.active_children %}
<ul class="ml-5">
<li>
<div class="flex items-center space-x-2">
<input type="checkbox" data-access-to="collection" data-access-to-id="{{ sub_collection.id }}" class="w-4 h-4 text-blue-600 bg-gray-300 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-400 dark:border-gray-600" />
<span class="text-center dark:text-gray-300">{{ sub_collection.label }}</span>
</div>
{% for section in sub_collection.sections %}
<ul class="ml-4">
<li>
<div class="flex items-center space-x-2">
<input type="checkbox" data-access-to="section" data-access-to-id="{{ section.id }}" class="w-4 h-4 text-blue-600 bg-gray-300 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-400 dark:border-gray-600" />
<span class="text-center dark:text-gray-300">{{ section.label }}</span>
</div>
</li>
</ul>
{% endfor %}
</li>
</ul>
{% endfor %}
#}
{% else %}
{% for section in collection.sections %}
<ul class="ml-5">
<li>
<div class="flex items-center space-x-2">
<input type="checkbox" data-access-to="section" data-access-to-id="{{ section.id }}" class="w-4 h-4 text-purple-600 bg-purple-100 border-purple-400 rounded focus:ring-purple-500 dark:focus:ring-purple-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-purple-300 dark:border-purple-500" />
{% for section in collection.sections %}
<ul class="ml-5">
<li>
<div class="flex items-center space-x-2">
<input type="checkbox" data-access-to="section" data-access-to-id="{{ section.id }}" class="w-4 h-4 text-purple-600 bg-purple-100 border-purple-400 rounded focus:ring-purple-500 dark:focus:ring-purple-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-purple-300 dark:border-purple-500" />
<span class="text-center dark:text-gray-300">{{ section.label }}</span>
</div>
</li>
</ul>
{% endfor %}
<span class="text-center dark:text-gray-300">{{ section.label }}</span>
</div>
</li>
</ul>
{% endfor %}
{% endif %}

View File

@ -88,7 +88,6 @@
{% if book.user_id == current_user.id %}
<div class="hidden px-4 rounded-lg bg-gray-50 dark:bg-gray-800" id="permissions" role="tabpanel" aria-labelledby="permissions-tab">
<div class="px-5">
<div class="mb-3 relative overflow-x-auto shadow-md sm:rounded-lg">
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400">
<thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400">

View File

@ -12,6 +12,10 @@ from app.controllers import (
)
from app import models as m, db, forms as f
from app.controllers.require_permission import require_permission
from app.controllers.contributor import (
add_contributor_to_book,
delete_contributor_from_book,
)
from app.logger import log
from .bp import bp
@ -48,38 +52,8 @@ def add_contributor(book_id: int):
form = f.AddContributorForm()
selected_tab = "user_permissions"
if form.validate_on_submit():
user_id = form.user_id.data
book_contributor = m.BookContributor.query.filter_by(
user_id=user_id, book_id=book_id
).first()
if book_contributor:
log(log.INFO, "Contributor: [%s] already exists", book_contributor)
flash("Already exists!", "danger")
return redirect(
url_for("book.settings", selected_tab=selected_tab, book_id=book_id)
)
role = m.BookContributor.Roles(int(form.role.data))
contributor = m.BookContributor(user_id=user_id, book_id=book_id, role=role)
log(log.INFO, "New contributor [%s]", contributor)
contributor.save()
groups = (
db.session.query(m.AccessGroup)
.filter(
m.BookAccessGroups.book_id == book_id,
m.AccessGroup.id == m.BookAccessGroups.access_group_id,
m.AccessGroup.name == role.name.lower(),
)
.all()
)
for group in groups:
m.UserAccessGroups(user_id=user_id, access_group_id=group.id).save()
flash("Contributor was added!", "success")
return redirect(
url_for("book.settings", selected_tab=selected_tab, book_id=book_id)
)
response = add_contributor_to_book(form, book_id, selected_tab)
return response
else:
log(log.ERROR, "Book create errors: [%s]", form.errors)
for field, errors in form.errors.items():
@ -104,47 +78,8 @@ def delete_contributor(book_id: int):
selected_tab = "user_permissions"
if form.validate_on_submit():
user_id = int(form.user_id.data)
book_contributor = m.BookContributor.query.filter_by(
user_id=user_id, book_id=book_id
).first()
if not book_contributor:
log(
log.INFO,
"BookContributor does not exists user: [%s], book: [%s]",
user_id,
book_id,
)
flash("Does not exists!", "success")
return redirect(
url_for("book.settings", selected_tab=selected_tab, book_id=book_id)
)
book: m.Book = db.session.get(m.Book, book_id)
user: m.User = db.session.get(m.User, user_id)
for access_group in book.access_groups:
access_group: m.AccessGroup
if user in access_group.users:
log(
log.INFO,
"Delete user [%s] from AccessGroup [%s]",
user,
access_group,
)
relationships_to_delete = m.UserAccessGroups.query.filter_by(
user_id=user_id, access_group_id=access_group.id
).all()
for relationship in relationships_to_delete:
db.session.delete(relationship)
log(log.INFO, "Delete BookContributor [%s]", book_contributor)
db.session.delete(book_contributor)
db.session.commit()
flash("Success!", "success")
return redirect(
url_for("book.settings", selected_tab=selected_tab, book_id=book_id)
)
response = delete_contributor_from_book(form, book_id, selected_tab)
return response
else:
log(log.ERROR, "Delete contributor errors: [%s]", form.errors)
for field, errors in form.errors.items():

View File

@ -1,14 +1,9 @@
import json
from flask import redirect, url_for, Blueprint, flash, request
from flask_login import current_user
from app import forms as f, models as m, db
from app.logger import log
from app.controllers.create_access_groups import (
create_editor_group,
create_moderator_group,
)
from app.controllers.permission import set_access_level
bp = Blueprint("permission", __name__, url_prefix="/permission")
@ -24,84 +19,8 @@ def set_permissions():
log(log.INFO, "User: [%s] is not owner of book: [%s]", current_user, book)
flash("You are not owner of this book!", "danger")
return redirect(url_for("book.my_library"))
user_id = form.user_id.data
contributor: m.BookContributor = m.BookContributor.query.filter_by(
user_id=user_id, book_id=book_id
).first()
if not contributor:
log(
log.INFO,
"User: [%s] is not contributor of book: [%s]",
current_user,
book,
)
flash("User are not contributor of this book!", "danger")
return redirect(url_for("book.my_library"))
user: m.User = contributor.user
users_access_groups: list[m.AccessGroup] = list(
set(book.list_access_groups).intersection(user.access_groups)
)
if len(users_access_groups) > 1:
log(
log.WARNING,
"User: [%s] has more than 1 access group in book [%s]",
user,
book,
)
for users_access in users_access_groups:
users_access: m.AccessGroup
users_access.users.remove(user)
permissions_json = json.loads(form.permissions.data)
book_ids = permissions_json.get("book", [])
for book_id in book_ids:
entire_boot_access_group = m.AccessGroup.query.filter_by(
book_id=book_id, name=contributor.role.name.lower()
).first()
m.UserAccessGroups(
user_id=user.id, access_group_id=entire_boot_access_group.id
).save(False)
db.session.commit()
flash("Success!", "success")
return redirect(url_for("book.settings", book_id=book_id))
new_access_group = None
match contributor.role:
case m.BookContributor.Roles.EDITOR:
new_access_group = create_editor_group(book.id)
case m.BookContributor.Roles.MODERATOR:
new_access_group = create_moderator_group(book.id)
case _:
log(
log.CRITICAL,
"Unknown contributor's [%s] role: [%s]",
contributor,
contributor.role,
)
flash("Unknown contributor's role", "danger")
return redirect(url_for("book.settings", book_id=book_id))
m.UserAccessGroups(user_id=user.id, access_group_id=new_access_group.id).save(
False
)
collection_ids = permissions_json.get("collection", [])
for collection_id in collection_ids:
m.CollectionAccessGroups(
collection_id=collection_id, access_group_id=new_access_group.id
).save(False)
section_ids = permissions_json.get("section", [])
for section_id in section_ids:
m.SectionAccessGroups(
section_id=section_id, access_group_id=new_access_group.id
).save(False)
db.session.commit()
flash("Success!", "success")
return redirect(url_for("book.settings", book_id=book_id))
response = set_access_level(form, book)
return response
log(log.ERROR, "Errors edit contributor access level: [%s]", form.errors)
for field, errors in form.errors.items():

View File

@ -0,0 +1,39 @@
"""remove selected_interpretation_id
Revision ID: 776fd9579f1f
Revises: 7c8a5aefe801
Create Date: 2023-05-26 14:38:44.858319
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "776fd9579f1f"
down_revision = "96995454b90d"
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("sections", schema=None) as batch_op:
batch_op.drop_column("selected_interpretation_id")
# ### 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.add_column(
sa.Column(
"selected_interpretation_id",
sa.INTEGER(),
autoincrement=False,
nullable=True,
)
)
# ### end Alembic commands ###

View File

@ -0,0 +1,42 @@
"""remove mark fields
Revision ID: 7c8a5aefe801
Revises: 776fd9579f1f
Create Date: 2023-05-25 15:44:06.072076
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "7c8a5aefe801"
down_revision = "776fd9579f1f"
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("comments", schema=None) as batch_op:
batch_op.drop_column("marked")
with op.batch_alter_table("interpretations", schema=None) as batch_op:
batch_op.drop_column("marked")
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("interpretations", schema=None) as batch_op:
batch_op.add_column(
sa.Column("marked", sa.BOOLEAN(), autoincrement=False, nullable=True)
)
with op.batch_alter_table("comments", schema=None) as batch_op:
batch_op.add_column(
sa.Column("marked", sa.BOOLEAN(), autoincrement=False, nullable=True)
)
# ### end Alembic commands ###

View File

@ -0,0 +1,32 @@
"""user.is_super_user
Revision ID: a41f004cad1a
Revises: 7c8a5aefe801
Create Date: 2023-05-25 14:31:14.046066
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "a41f004cad1a"
down_revision = "7c8a5aefe801"
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("users", schema=None) as batch_op:
batch_op.add_column(sa.Column("is_super_user", sa.Boolean(), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("users", schema=None) as batch_op:
batch_op.drop_column("is_super_user")
# ### end Alembic commands ###

2500
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -2,13 +2,12 @@
name = "flask.app"
version = "0.1.0"
description = ""
authors = ["denys <denysburimov@gmail.com>"]
authors = ["Simple2b https://www.simple2b.net/"]
[tool.poetry.dependencies]
python = "^3.11"
flask = "^2.2.3"
flask-migrate = "^4.0.4"
flask-wtf = "^1.1.1"
flask-mail = "^0.9.1"
flask-login = "^0.6.2"
python-dotenv = "^1.0.0"
@ -17,6 +16,9 @@ email-validator = "^1.3.1"
psycopg2-binary = "^2.9.5"
pydantic = "^1.10.7"
siwe = "^2.2.0"
flask-admin = "^1.6.1"
wtforms = "^3.0.1"
flask-wtf = "^1.1.1"
[tool.poetry.dev-dependencies]
pytest = "^7.1.1"

View File

@ -1,3 +0,0 @@
[pytest]
filterwarnings =
ignore::DeprecationWarning

View File

@ -32,4 +32,17 @@ export function initRefreshAccessLevelTree() {
refreshAccessLevelTree(userId, bookId);
});
});
const trigger = document.querySelector('.trigger-refreshAccessLevelTree');
if (trigger) {
const userIdInput: HTMLInputElement = document.querySelector(
'input[name=user_id]',
);
const bookIdInput: HTMLInputElement = document.querySelector(
'input[name=book_id]',
);
const userId = userIdInput.value;
const bookId = bookIdInput.value;
refreshAccessLevelTree(userId, bookId);
}
}

View File

@ -119,14 +119,12 @@ def create_dummy_data():
text="Dummy Interpretation 2 About",
section_id=section_2_1_1.id,
user_id=user.id,
marked=True,
).save()
interpretation_3 = m.Interpretation(
text="Dummy Interpretation 3 About",
section_id=section_2_1_2.id,
user_id=user.id,
marked=True,
).save()
m.Interpretation(
@ -163,7 +161,6 @@ def create_dummy_data():
text="Dummy Comment 1.2 Text",
user_id=user_2.id,
parent_id=comment_1.id,
marked=True,
interpretation_id=interpretation_2.id,
).save()
@ -182,7 +179,6 @@ def create_dummy_data():
comment_3_1 = m.Comment(
text="Dummy Comment 3.1 Text",
user_id=user.id,
marked=True,
parent_id=comment_3.id,
interpretation_id=interpretation_2.id,
).save()

View File

@ -161,7 +161,6 @@ def test_dummy_data(runner: FlaskCliRunner):
).first()
assert interpretation_2
assert interpretation_2.marked
assert interpretation_2.user == user
assert interpretation_2.section == section_2_1_1
@ -170,7 +169,6 @@ def test_dummy_data(runner: FlaskCliRunner):
).first()
assert interpretation_3
assert interpretation_3.marked
assert interpretation_3.user == user
assert interpretation_3.section == section_2_1_2
@ -211,7 +209,6 @@ def test_dummy_data(runner: FlaskCliRunner):
assert comment_1_1 in comment_1.children
assert comment_1_2.parent == comment_1
assert comment_1_2.parent == comment_1
assert comment_1_2.marked
comment_2: m.Comment = m.Comment.query.filter_by(
text="Dummy Comment 2 Text"
@ -242,7 +239,6 @@ def test_dummy_data(runner: FlaskCliRunner):
assert comment_3_1 in comment_3.children
assert comment_3_2 in comment_3.children
assert comment_3_3 in comment_3.children
assert comment_3_1.marked
assert comment_3_2.approved
assert comment_1 in interpretation_2.comments

View File

@ -4,3 +4,9 @@ max-line-length = 120
;exclude = tests/*
;max-complexity = 10
exclude = .git,__pycache__,.venv/,migrations/,node_modules/
[pytest]
filterwarnings =
ignore::DeprecationWarning
addopts = -p no:warnings