diff --git a/migrations/README b/migrations/README new file mode 100644 index 0000000..0e04844 --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Single-database configuration for Flask. diff --git a/migrations/alembic.ini b/migrations/alembic.ini new file mode 100644 index 0000000..ec9d45c --- /dev/null +++ b/migrations/alembic.ini @@ -0,0 +1,50 @@ +# A generic, single database configuration. + +[alembic] +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic,flask_migrate + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[logger_flask_migrate] +level = INFO +handlers = +qualname = flask_migrate + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..4c97092 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,113 @@ +import logging +from logging.config import fileConfig + +from flask import current_app + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) +logger = logging.getLogger('alembic.env') + + +def get_engine(): + try: + # this works with Flask-SQLAlchemy<3 and Alchemical + return current_app.extensions['migrate'].db.get_engine() + except (TypeError, AttributeError): + # this works with Flask-SQLAlchemy>=3 + return current_app.extensions['migrate'].db.engine + + +def get_engine_url(): + try: + return get_engine().url.render_as_string(hide_password=False).replace( + '%', '%%') + except AttributeError: + return str(get_engine().url).replace('%', '%%') + + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +config.set_main_option('sqlalchemy.url', get_engine_url()) +target_db = current_app.extensions['migrate'].db + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def get_metadata(): + if hasattr(target_db, 'metadatas'): + return target_db.metadatas[None] + return target_db.metadata + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, target_metadata=get_metadata(), literal_binds=True + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + # this callback is used to prevent an auto-migration from being generated + # when there are no changes to the schema + # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html + def process_revision_directives(context, revision, directives): + if getattr(config.cmd_opts, 'autogenerate', False): + script = directives[0] + if script.upgrade_ops.is_empty(): + directives[:] = [] + logger.info('No changes in schema detected.') + + conf_args = current_app.extensions['migrate'].configure_args + if conf_args.get("process_revision_directives") is None: + conf_args["process_revision_directives"] = process_revision_directives + + connectable = get_engine() + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=get_metadata(), + **conf_args + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..2c01563 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/70be0dbb9fbb_initial_migration.py b/migrations/versions/70be0dbb9fbb_initial_migration.py new file mode 100644 index 0000000..88a24f5 --- /dev/null +++ b/migrations/versions/70be0dbb9fbb_initial_migration.py @@ -0,0 +1,131 @@ +"""initial_migration + +Revision ID: 70be0dbb9fbb +Revises: +Create Date: 2023-09-18 21:01:48.648924 + +""" +import flask_security +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "70be0dbb9fbb" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "role", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(length=80), nullable=False), + sa.Column("description", sa.String(length=255), nullable=True), + sa.Column("permissions", flask_security.datastore.AsaList(), nullable=True), + sa.Column( + "update_datetime", + sa.DateTime(), + server_default=sa.text("now()"), + nullable=False, + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("name"), + ) + op.create_table( + "tag", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("tag", sa.String(length=20), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "user", + sa.Column("first_name", sa.String(length=255), nullable=True), + sa.Column("last_name", sa.String(length=255), nullable=True), + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("email", sa.String(length=255), nullable=False), + sa.Column("username", sa.String(length=255), nullable=True), + sa.Column("password", sa.String(length=255), nullable=False), + sa.Column("active", sa.Boolean(), nullable=False), + sa.Column("fs_uniquifier", sa.String(length=64), nullable=False), + sa.Column("confirmed_at", sa.DateTime(), nullable=True), + sa.Column("last_login_at", sa.DateTime(), nullable=True), + sa.Column("current_login_at", sa.DateTime(), nullable=True), + sa.Column("last_login_ip", sa.String(length=64), nullable=True), + sa.Column("current_login_ip", sa.String(length=64), nullable=True), + sa.Column("login_count", sa.Integer(), nullable=True), + sa.Column("tf_primary_method", sa.String(length=64), nullable=True), + sa.Column("tf_totp_secret", sa.String(length=255), nullable=True), + sa.Column("tf_phone_number", sa.String(length=128), nullable=True), + sa.Column( + "create_datetime", + sa.DateTime(), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "update_datetime", + sa.DateTime(), + server_default=sa.text("now()"), + nullable=False, + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("email"), + sa.UniqueConstraint("fs_uniquifier"), + ) + op.create_table( + "post", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("author", sa.Integer(), nullable=True), + sa.Column("slug", sa.String(length=30), nullable=True), + sa.Column("title", sa.String(length=50), nullable=False), + sa.Column("published", sa.Boolean(), nullable=True), + sa.Column("create_datetime", sa.DateTime(), nullable=True), + sa.Column("update_datetime", sa.DateTime(), nullable=True), + sa.Column("text", sa.Text(), nullable=True), + sa.ForeignKeyConstraint( + ["author"], + ["user.id"], + ), + sa.PrimaryKeyConstraint("id", "slug"), + sa.UniqueConstraint("id"), + ) + op.create_table( + "roles_users", + sa.Column("user_id", sa.Integer(), nullable=True), + sa.Column("role_id", sa.Integer(), nullable=True), + sa.ForeignKeyConstraint( + ["role_id"], + ["role.id"], + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["user.id"], + ), + ) + op.create_table( + "tag_post", + sa.Column("tag_id", sa.Integer(), nullable=True), + sa.Column("post_id", sa.Integer(), nullable=True), + sa.ForeignKeyConstraint( + ["post_id"], + ["post.id"], + ), + sa.ForeignKeyConstraint( + ["tag_id"], + ["tag.id"], + ), + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("tag_post") + op.drop_table("roles_users") + op.drop_table("post") + op.drop_table("user") + op.drop_table("tag") + op.drop_table("role") + # ### end Alembic commands ### diff --git a/poetry.lock b/poetry.lock index 24ec8d6..d925115 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,23 @@ -# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.5.1 and should not be changed by hand. + +[[package]] +name = "alembic" +version = "1.12.0" +description = "A database migration tool for SQLAlchemy." +optional = false +python-versions = ">=3.7" +files = [ + {file = "alembic-1.12.0-py3-none-any.whl", hash = "sha256:03226222f1cf943deee6c85d9464261a6c710cd19b4fe867a3ad1f25afda610f"}, + {file = "alembic-1.12.0.tar.gz", hash = "sha256:8e7645c32e4f200675e69f0745415335eb59a3663f5feb487abfa0b30c45888b"}, +] + +[package.dependencies] +Mako = "*" +SQLAlchemy = ">=1.3.0" +typing-extensions = ">=4" + +[package.extras] +tz = ["python-dateutil"] [[package]] name = "blinker" @@ -92,6 +111,25 @@ Werkzeug = ">=2.3.7" async = ["asgiref (>=3.2)"] dotenv = ["python-dotenv"] +[[package]] +name = "flask-admin" +version = "1.6.1" +description = "Simple and extensible admin interface framework for Flask" +optional = false +python-versions = ">=3.6" +files = [ + {file = "Flask-Admin-1.6.1.tar.gz", hash = "sha256:24cae2af832b6a611a01d7dc35f42d266c1d6c75a426b869d8cb241b78233369"}, + {file = "Flask_Admin-1.6.1-py3-none-any.whl", hash = "sha256:fd8190f1ec3355913a22739c46ed3623f1d82b8112cde324c60a6fc9b21c9406"}, +] + +[package.dependencies] +Flask = ">=0.7" +wtforms = "*" + +[package.extras] +aws = ["boto"] +azure = ["azure-storage-blob"] + [[package]] name = "flask-login" version = "0.6.2" @@ -107,6 +145,22 @@ files = [ Flask = ">=1.0.4" Werkzeug = ">=1.0.1" +[[package]] +name = "flask-migrate" +version = "4.0.5" +description = "SQLAlchemy database migrations for Flask applications using Alembic." +optional = false +python-versions = ">=3.6" +files = [ + {file = "Flask-Migrate-4.0.5.tar.gz", hash = "sha256:d3f437a8b5f3849d1bb1b60e1b818efc564c66e3fefe90b62e5db08db295e1b1"}, + {file = "Flask_Migrate-4.0.5-py3-none-any.whl", hash = "sha256:613a2df703998e78716cace68cd83972960834424457f5b67f56e74fff950aef"}, +] + +[package.dependencies] +alembic = ">=1.9.0" +Flask = ">=0.9" +Flask-SQLAlchemy = ">=1.0" + [[package]] name = "flask-principal" version = "0.4.0" @@ -195,7 +249,6 @@ files = [ {file = "greenlet-2.0.2-cp27-cp27m-win32.whl", hash = "sha256:6c3acb79b0bfd4fe733dff8bc62695283b57949ebcca05ae5c129eb606ff2d74"}, {file = "greenlet-2.0.2-cp27-cp27m-win_amd64.whl", hash = "sha256:283737e0da3f08bd637b5ad058507e578dd462db259f7f6e4c5c365ba4ee9343"}, {file = "greenlet-2.0.2-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:d27ec7509b9c18b6d73f2f5ede2622441de812e7b1a80bbd446cb0633bd3d5ae"}, - {file = "greenlet-2.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d967650d3f56af314b72df7089d96cda1083a7fc2da05b375d2bc48c82ab3f3c"}, {file = "greenlet-2.0.2-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:30bcf80dda7f15ac77ba5af2b961bdd9dbc77fd4ac6105cee85b0d0a5fcf74df"}, {file = "greenlet-2.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26fbfce90728d82bc9e6c38ea4d038cba20b7faf8a0ca53a9c07b67318d46088"}, {file = "greenlet-2.0.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9190f09060ea4debddd24665d6804b995a9c122ef5917ab26e1566dcc712ceeb"}, @@ -204,7 +257,6 @@ files = [ {file = "greenlet-2.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:76ae285c8104046b3a7f06b42f29c7b73f77683df18c49ab5af7983994c2dd91"}, {file = "greenlet-2.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:2d4686f195e32d36b4d7cf2d166857dbd0ee9f3d20ae349b6bf8afc8485b3645"}, {file = "greenlet-2.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c4302695ad8027363e96311df24ee28978162cdcdd2006476c43970b384a244c"}, - {file = "greenlet-2.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d4606a527e30548153be1a9f155f4e283d109ffba663a15856089fb55f933e47"}, {file = "greenlet-2.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c48f54ef8e05f04d6eff74b8233f6063cb1ed960243eacc474ee73a2ea8573ca"}, {file = "greenlet-2.0.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a1846f1b999e78e13837c93c778dcfc3365902cfb8d1bdb7dd73ead37059f0d0"}, {file = "greenlet-2.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a06ad5312349fec0ab944664b01d26f8d1f05009566339ac6f63f56589bc1a2"}, @@ -234,7 +286,6 @@ files = [ {file = "greenlet-2.0.2-cp37-cp37m-win32.whl", hash = "sha256:3f6ea9bd35eb450837a3d80e77b517ea5bc56b4647f5502cd28de13675ee12f7"}, {file = "greenlet-2.0.2-cp37-cp37m-win_amd64.whl", hash = "sha256:7492e2b7bd7c9b9916388d9df23fa49d9b88ac0640db0a5b4ecc2b653bf451e3"}, {file = "greenlet-2.0.2-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:b864ba53912b6c3ab6bcb2beb19f19edd01a6bfcbdfe1f37ddd1778abfe75a30"}, - {file = "greenlet-2.0.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:1087300cf9700bbf455b1b97e24db18f2f77b55302a68272c56209d5587c12d1"}, {file = "greenlet-2.0.2-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:ba2956617f1c42598a308a84c6cf021a90ff3862eddafd20c3333d50f0edb45b"}, {file = "greenlet-2.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc3a569657468b6f3fb60587e48356fe512c1754ca05a564f11366ac9e306526"}, {file = "greenlet-2.0.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8eab883b3b2a38cc1e050819ef06a7e6344d4a990d24d45bc6f2cf959045a45b"}, @@ -243,7 +294,6 @@ files = [ {file = "greenlet-2.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b0ef99cdbe2b682b9ccbb964743a6aca37905fda5e0452e5ee239b1654d37f2a"}, {file = "greenlet-2.0.2-cp38-cp38-win32.whl", hash = "sha256:b80f600eddddce72320dbbc8e3784d16bd3fb7b517e82476d8da921f27d4b249"}, {file = "greenlet-2.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:4d2e11331fc0c02b6e84b0d28ece3a36e0548ee1a1ce9ddde03752d9b79bba40"}, - {file = "greenlet-2.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8512a0c38cfd4e66a858ddd1b17705587900dd760c6003998e9472b77b56d417"}, {file = "greenlet-2.0.2-cp39-cp39-macosx_11_0_x86_64.whl", hash = "sha256:88d9ab96491d38a5ab7c56dd7a3cc37d83336ecc564e4e8816dbed12e5aaefc8"}, {file = "greenlet-2.0.2-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:561091a7be172ab497a3527602d467e2b3fbe75f9e783d8b8ce403fa414f71a6"}, {file = "greenlet-2.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:971ce5e14dc5e73715755d0ca2975ac88cfdaefcaab078a284fea6cfabf866df"}, @@ -334,6 +384,25 @@ MarkupSafe = ">=2.0" [package.extras] i18n = ["Babel (>=2.7)"] +[[package]] +name = "mako" +version = "1.2.4" +description = "A super-fast templating language that borrows the best ideas from the existing templating languages." +optional = false +python-versions = ">=3.7" +files = [ + {file = "Mako-1.2.4-py3-none-any.whl", hash = "sha256:c97c79c018b9165ac9922ae4f32da095ffd3c4e6872b45eded42926deea46818"}, + {file = "Mako-1.2.4.tar.gz", hash = "sha256:d60a3903dc3bb01a18ad6a89cdbe2e4eadc69c0bc8ef1e3773ba53d44c3f7a34"}, +] + +[package.dependencies] +MarkupSafe = ">=0.9.2" + +[package.extras] +babel = ["Babel"] +lingua = ["lingua"] +testing = ["pytest"] + [[package]] name = "markupsafe" version = "2.1.3" @@ -361,16 +430,6 @@ files = [ {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac"}, {file = "MarkupSafe-2.1.3-cp311-cp311-win32.whl", hash = "sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb"}, {file = "MarkupSafe-2.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f698de3fd0c4e6972b92290a45bd9b1536bffe8c6759c62471efaa8acb4c37bc"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aa57bd9cf8ae831a362185ee444e15a93ecb2e344c8e52e4d721ea3ab6ef1823"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffcc3f7c66b5f5b7931a5aa68fc9cecc51e685ef90282f4a82f0f5e9b704ad11"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47d4f1c5f80fc62fdd7777d0d40a2e9dda0a05883ab11374334f6c4de38adffd"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1f67c7038d560d92149c060157d623c542173016c4babc0c1913cca0564b9939"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:9aad3c1755095ce347e26488214ef77e0485a3c34a50c5a5e2471dff60b9dd9c"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:14ff806850827afd6b07a5f32bd917fb7f45b046ba40c57abdb636674a8b559c"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8f9293864fe09b8149f0cc42ce56e3f0e54de883a9de90cd427f191c346eb2e1"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-win32.whl", hash = "sha256:715d3562f79d540f251b99ebd6d8baa547118974341db04f5ad06d5ea3eb8007"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:1b8dd8c3fd14349433c79fa8abeb573a55fc0fdd769133baac1f5e07abf54aeb"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707"}, @@ -431,6 +490,26 @@ bcrypt = ["bcrypt (>=3.1.0)"] build-docs = ["cloud-sptheme (>=1.10.1)", "sphinx (>=1.6)", "sphinxcontrib-fulltoc (>=1.2.0)"] totp = ["cryptography"] +[[package]] +name = "psycopg2" +version = "2.9.7" +description = "psycopg2 - Python-PostgreSQL Database Adapter" +optional = false +python-versions = ">=3.6" +files = [ + {file = "psycopg2-2.9.7-cp310-cp310-win32.whl", hash = "sha256:1a6a2d609bce44f78af4556bea0c62a5e7f05c23e5ea9c599e07678995609084"}, + {file = "psycopg2-2.9.7-cp310-cp310-win_amd64.whl", hash = "sha256:b22ed9c66da2589a664e0f1ca2465c29b75aaab36fa209d4fb916025fb9119e5"}, + {file = "psycopg2-2.9.7-cp311-cp311-win32.whl", hash = "sha256:44d93a0109dfdf22fe399b419bcd7fa589d86895d3931b01fb321d74dadc68f1"}, + {file = "psycopg2-2.9.7-cp311-cp311-win_amd64.whl", hash = "sha256:91e81a8333a0037babfc9fe6d11e997a9d4dac0f38c43074886b0d9dead94fe9"}, + {file = "psycopg2-2.9.7-cp37-cp37m-win32.whl", hash = "sha256:d1210fcf99aae6f728812d1d2240afc1dc44b9e6cba526a06fb8134f969957c2"}, + {file = "psycopg2-2.9.7-cp37-cp37m-win_amd64.whl", hash = "sha256:e9b04cbef584310a1ac0f0d55bb623ca3244c87c51187645432e342de9ae81a8"}, + {file = "psycopg2-2.9.7-cp38-cp38-win32.whl", hash = "sha256:d5c5297e2fbc8068d4255f1e606bfc9291f06f91ec31b2a0d4c536210ac5c0a2"}, + {file = "psycopg2-2.9.7-cp38-cp38-win_amd64.whl", hash = "sha256:8275abf628c6dc7ec834ea63f6f3846bf33518907a2b9b693d41fd063767a866"}, + {file = "psycopg2-2.9.7-cp39-cp39-win32.whl", hash = "sha256:c7949770cafbd2f12cecc97dea410c514368908a103acf519f2a346134caa4d5"}, + {file = "psycopg2-2.9.7-cp39-cp39-win_amd64.whl", hash = "sha256:b6bd7d9d3a7a63faae6edf365f0ed0e9b0a1aaf1da3ca146e6b043fb3eb5d723"}, + {file = "psycopg2-2.9.7.tar.gz", hash = "sha256:f00cc35bd7119f1fed17b85bd1007855194dde2cbd8de01ab8ebb17487440ad8"}, +] + [[package]] name = "sqlalchemy" version = "2.0.20" @@ -482,7 +561,7 @@ files = [ ] [package.dependencies] -greenlet = {version = "!=0.4.17", markers = "platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\""} +greenlet = {version = "!=0.4.17", markers = "platform_machine == \"win32\" or platform_machine == \"WIN32\" or platform_machine == \"AMD64\" or platform_machine == \"amd64\" or platform_machine == \"x86_64\" or platform_machine == \"ppc64le\" or platform_machine == \"aarch64\""} typing-extensions = ">=4.2.0" [package.extras] @@ -557,4 +636,4 @@ email = ["email-validator"] [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "3b8dfa8cb4d25075ed8465782a8fdbcbf9004ea82ea533898f9bb797f55025a3" +content-hash = "0b3e5f508d905b937d323c2545bbba6bcf2ee7a5f1227c549cf0d12e49ffac99" diff --git a/pyproger/admin/__init__.py b/pyproger/admin/__init__.py new file mode 100644 index 0000000..cc7326c --- /dev/null +++ b/pyproger/admin/__init__.py @@ -0,0 +1,2 @@ +from . import views +from .admin import admin diff --git a/pyproger/admin/admin.py b/pyproger/admin/admin.py new file mode 100644 index 0000000..12c2485 --- /dev/null +++ b/pyproger/admin/admin.py @@ -0,0 +1,8 @@ +from flask_admin import Admin + +admin = Admin( + name="Админ панель", + url="/admin", + base_template="my_master.html", + template_mode="bootstrap4", +) diff --git a/pyproger/admin/views.py b/pyproger/admin/views.py new file mode 100644 index 0000000..4e38cd2 --- /dev/null +++ b/pyproger/admin/views.py @@ -0,0 +1,61 @@ +from flask import abort, redirect, request, url_for +from flask_admin.contrib import sqla +from flask_security import current_user + + +class MyAdminView(sqla.ModelView): + def is_accessible(self): + return ( + current_user.is_active + and current_user.is_authenticated + and current_user.has_role("superuser") + ) + + def _handle_view(self, name, **kwargs): + """ + Override builtin _handle_view in + order to redirect users when a + view is not accessible. + """ + if not self.is_accessible(): + if current_user.is_authenticated: + # permission denied + abort(403) + else: + # login + return redirect(url_for("security.login", next=request.url)) + + +class UserView(MyAdminView): + column_hide_backrefs = False + column_list = ( + "email", + "active", + "roles", + ) + + +class RoleView(MyAdminView): + column_list = ( + "name", + "description", + ) + + +class TagView(MyAdminView): + pass + + +class PostView(MyAdminView): + # form_excluded_columns = ("author", "create_datetime", "update_datetime") + column_list = ( + "title", + "published", + ) + column_labels = dict( + tags="Tags", + title="Title", + author="Author", + published="Published", + published_datetime="Pubdate", + ) diff --git a/pyproger/app.py b/pyproger/app.py old mode 100755 new mode 100644 index 7e4b09b..ee816f3 --- a/pyproger/app.py +++ b/pyproger/app.py @@ -1,12 +1,58 @@ -from flask import Flask, render_template_string +import os + +from flask import Flask, render_template_string, request, url_for +from flask_admin import helpers +from flask_migrate import Migrate +from flask_security.core import Security + +from pyproger.dbase import Role, User, db, user_datastore +from pyproger.dbase.models import Post, Tag -def create_app(): +def create_app(test_config=None): app = Flask(__name__) - @app.route("/index") - @app.route("/") - def index() -> str: - return render_template_string("pyproger temporary page") + if test_config is None: + app.config.from_pyfile("config.py", silent=True) + else: + app.config.from_mapping(test_config) + + # Проверям/создаем папку instanse + try: + os.makedirs(app.instance_path) + except OSError: + pass + + db.init_app(app) + + security = Security(app, user_datastore) + + migrate = Migrate(db=db) + migrate.init_app(app) + + from .admin import admin + + admin.init_app(app) + + from pyproger.admin.views import PostView, RoleView, TagView, UserView + + admin.add_view(RoleView(Role, db.session, category="admin")) + admin.add_view(UserView(User, db.session, category="admin")) + admin.add_view(TagView(Tag, db.session, category="posts")) + admin.add_view(PostView(Post, db.session, category="posts")) + + @security.context_processor + def security_context_processor(): + return dict( + admin_base_template=admin.base_template, + admin_view=admin.index_view, + h=helpers, + get_url=url_for, + ) + + @app.route("/ping") + def hello(): + logging.info("Проверка ping-pong") + return render_template_string("pong") return app diff --git a/pyproger/config.py b/pyproger/config.py new file mode 100755 index 0000000..b480ab1 --- /dev/null +++ b/pyproger/config.py @@ -0,0 +1,37 @@ +FLASK_ADMIN_SWATCH = "slate" +# Create secret key so we can use sessions +# python3: secrets.token_urlsafe() +SECRET_KEY = "hxfjbcfry52" + +# Create in-memory database +SQLALCHEMY_DATABASE_URI = "postgresql+psycopg2://pi3c:@localhost/pyproger" +# For debug - show every DB query +SQLALCHEMY_ECHO = False + +# Flask-Security config +SECURITY_URL_PREFIX = "/admin" +SECURITY_PASSWORD_HASH = "pbkdf2_sha512" +SECURITY_PASSWORD_SALT = "ATGUOHAELKiubahiughaerGOJAEGj" +SECURITY_TRACKABLE = True + +# Flask-Security URLs, overridden because they don't put a / at the end +SECURITY_LOGIN_URL = "/login/" +SECURITY_LOGOUT_URL = "/logout/" +SECURITY_REGISTER_URL = "/register/" + +SECURITY_POST_LOGIN_VIEW = "/admin/" +SECURITY_POST_LOGOUT_VIEW = "/admin/" +SECURITY_POST_REGISTER_VIEW = "/admin/" +SECURITY_POST_RESET_VIEW = "/admin/" + +# Flask-Security features +SECURITY_REGISTERABLE = False +SECURITY_CHANGEABLE = True +SECURITY_RECOVERABLE = False +SQLALCHEMY_TRACK_MODIFICATIONS = False + +# For demo - no email +SECURITY_SEND_REGISTER_EMAIL = False +SECURITY_SEND_PASSWORD_CHANGE_EMAIL = False +SECURITY_SEND_PASSWORD_RESET_EMAIL = False +SECURITY_SEND_PASSWORD_RESET_NOTICE_EMAIL = False diff --git a/pyproger/dbase/__init__.py b/pyproger/dbase/__init__.py new file mode 100644 index 0000000..20a0ae7 --- /dev/null +++ b/pyproger/dbase/__init__.py @@ -0,0 +1,10 @@ +from flask_security.datastore import SQLAlchemyUserDatastore +from flask_security.models import fsqla +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() +fsqla.FsModels.set_db_info(db) + +from .models import Role, User + +user_datastore = SQLAlchemyUserDatastore(db, User, Role) diff --git a/pyproger/dbase/models.py b/pyproger/dbase/models.py new file mode 100644 index 0000000..d6a4f2c --- /dev/null +++ b/pyproger/dbase/models.py @@ -0,0 +1,57 @@ +import datetime + +from flask_security.models import fsqla +from sqlalchemy import Boolean, Column, DateTime, Integer, String, Text +from sqlalchemy.util import unique_list + +from . import db + + +class Role(db.Model, fsqla.FsRoleMixin): + def __str__(self) -> str: + return self.name + + +class User(db.Model, fsqla.FsUserMixin): + first_name = Column(String(255)) + last_name = Column(String(255)) + posts = db.relationship("Post", backref="user", lazy="dynamic") + + def __str__(self) -> str: + return self.email + + +tag_post = db.Table( + "tag_post", + db.Column("tag_id", db.Integer, db.ForeignKey("tag.id")), + db.Column("post_id", db.Integer, db.ForeignKey("post.id")), +) + + +class Tag(db.Model): + __tablename__ = "tag" + + id = Column(Integer, primary_key=True) + tag = Column(String(20)) + + def __str__(self) -> str: + return self.tag + + +class Post(db.Model): + __tablename__ = "post" + id = Column(Integer, primary_key=True, nullable=False, unique=True) + author = Column(Integer, db.ForeignKey("user.id")) + slug = Column(String(30), primary_key=True, nullable=True) + title = Column(String(50), nullable=False) + published = Column(Boolean, default=False) + tags = db.relationship("Tag", secondary=tag_post) + + create_datetime = Column( + DateTime(), nullable=True, default=datetime.datetime.utcnow() + ) + update_datetime = Column( + DateTime(), + nullable=True, + ) + text = Column(Text) diff --git a/pyproger/templates/admin/index.html b/pyproger/templates/admin/index.html new file mode 100755 index 0000000..6f39406 --- /dev/null +++ b/pyproger/templates/admin/index.html @@ -0,0 +1,39 @@ +{% extends 'admin/master.html' %} +{% block body %} +{{ super() }} +
+ Авторизация +
++ Админ панель блога pyproger.ru +
+ {% if not current_user.is_authenticated %} +Войдите используя Ваш логин и пароль.
+
+ По всем вопросам обращайтесь к администратору:
+
+ Войти +
++ {% else %} + {% if current_user.first_name %} + {{ current_user.first_name }} + {% else %} + {{ current_user.email }} + {% endif %}, добро пожаловать. + {% endif %} +
+ На главную +
+{{ field(class_='form-control', **kwargs)|safe }}
+{% endmacro %} + +{% macro render_checkbox_field(field) -%} +Already signed up? Please log in.
+