├── utils ├── __init__.py └── forms.py ├── static ├── uploads │ └── .gitkeep ├── images │ └── python-logo-master-v3-TM-flattened.png └── styles │ └── mini-default.min.css ├── migrations ├── README ├── script.py.mako ├── versions │ ├── c1d2f0f92245_.py │ ├── a363462cb5a4_.py │ ├── a70d7f84a0c8_.py │ ├── c2376ca71a2c_.py │ ├── 4f0b9f08febc_.py │ ├── 96afae1f7fd7_.py │ └── a967f45ee27d_.py ├── alembic.ini └── env.py ├── .gitignore ├── .env.example ├── mod_uploads ├── __init__.py ├── forms.py └── models.py ├── mod_blog ├── __init__.py ├── forms.py ├── models.py └── views.py ├── mod_users ├── __init__.py ├── forms.py ├── models.py ├── utils.py └── views.py ├── mod_admin ├── __init__.py ├── utils.py └── views.py ├── views.py ├── templates ├── index.html ├── admin │ ├── index.html │ ├── upload_file.html │ ├── create_category.html │ ├── modify_category.html │ ├── base.html │ ├── create_post.html │ ├── modify_post.html │ ├── _includes │ │ └── navigation.html │ ├── list_users.html │ ├── login.html │ ├── list_posts.html │ ├── list_categories.html │ └── create_user.html ├── blog │ ├── base.html │ ├── single_post.html │ ├── single_category.html │ ├── search.html │ ├── _includes │ │ └── navigation.html │ └── index.html ├── base.html └── users │ └── register.html ├── requirements.txt ├── config.py ├── app.py ├── Relation_Examples.md └── README.md /utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /static/uploads/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | venv/ 2 | .vscode/ 3 | __pycache__/ 4 | .env 5 | static/uploads/* 6 | !static/uploads/.gitkeep -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | SQLALCHEMY_DATABASE_URI= 2 | SECRET_KEY= 3 | MAIL_SERVER= 4 | MAIL_USERNAME= 5 | MAIL_PASSWORD= 6 | REDIS_SERVER_URL= -------------------------------------------------------------------------------- /mod_uploads/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | 4 | uploads = Blueprint('uploads', __name__) 5 | 6 | 7 | from . import models -------------------------------------------------------------------------------- /mod_blog/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | blog = Blueprint('blog', __name__, url_prefix='/blog/') 4 | 5 | from . import views 6 | -------------------------------------------------------------------------------- /mod_users/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | users = Blueprint('users', __name__, url_prefix='/users/') 4 | 5 | from . import views 6 | -------------------------------------------------------------------------------- /mod_admin/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | admin = Blueprint('admin', __name__, url_prefix='/admin/') 4 | 5 | from .views import index 6 | -------------------------------------------------------------------------------- /views.py: -------------------------------------------------------------------------------- 1 | from flask import render_template 2 | from app import app 3 | 4 | 5 | @app.route('/') 6 | def index(): 7 | return render_template('index.html') -------------------------------------------------------------------------------- /static/images/python-logo-master-v3-TM-flattened.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DarkSuniuM/FlaskBlog/HEAD/static/images/python-logo-master-v3-TM-flattened.png -------------------------------------------------------------------------------- /templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block title %}Home page{% endblock %} 4 | 5 | {% block content %} 6 |

Home page | Flask Blog

7 | {% endblock %} 8 | -------------------------------------------------------------------------------- /templates/admin/index.html: -------------------------------------------------------------------------------- 1 | {% extends 'admin/base.html' %} 2 | 3 | {% block title %}Admin Dashboard{% endblock %} 4 | 5 | {% block page_content %} 6 | Welcome dear admin. 7 | {% endblock %} -------------------------------------------------------------------------------- /templates/blog/base.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | 4 | 5 | {% block content %} 6 | {% include 'blog/_includes/navigation.html' %} 7 | {% block page_content %} 8 | {% endblock %} 9 | {% endblock %} -------------------------------------------------------------------------------- /mod_uploads/forms.py: -------------------------------------------------------------------------------- 1 | from flask_wtf import FlaskForm 2 | from wtforms import FileField 3 | from wtforms.validators import DataRequired 4 | 5 | 6 | class FileUploadForm(FlaskForm): 7 | file = FileField(validators=[DataRequired()]) -------------------------------------------------------------------------------- /templates/blog/single_post.html: -------------------------------------------------------------------------------- 1 | {% extends 'blog/base.html' %} 2 | 3 | {% block title %}{{ post.title }}{% endblock %} 4 | 5 | 6 | {% block page_content %} 7 |

{{ post.title }}

8 |

{{ post.content }}

9 | {% endblock %} 10 | -------------------------------------------------------------------------------- /utils/forms.py: -------------------------------------------------------------------------------- 1 | from wtforms import SelectMultipleField 2 | from wtforms.widgets import ListWidget, CheckboxInput 3 | 4 | 5 | class MultipleCheckboxField(SelectMultipleField): 6 | widget = ListWidget(prefix_label=False) 7 | option_widget = CheckboxInput() 8 | -------------------------------------------------------------------------------- /mod_uploads/models.py: -------------------------------------------------------------------------------- 1 | from app import db 2 | from sqlalchemy import Column, Integer, String, DateTime 3 | import datetime as dt 4 | 5 | 6 | class File(db.Model): 7 | id = Column(Integer, primary_key=True) 8 | filename = Column(String(256), nullable=False, unique=True) 9 | upload_date = Column(DateTime(), nullable=False, unique=False, default=dt.datetime.utcnow) 10 | -------------------------------------------------------------------------------- /mod_admin/utils.py: -------------------------------------------------------------------------------- 1 | from flask import session, abort 2 | from functools import wraps 3 | 4 | 5 | def admin_only_view(func): 6 | @wraps(func) 7 | def decorator(*args, **kwargs): 8 | if session.get('user_id') is None: 9 | abort(401) 10 | if session.get('role') != 1: 11 | abort(403) 12 | return func(*args, **kwargs) 13 | return decorator 14 | -------------------------------------------------------------------------------- /templates/admin/upload_file.html: -------------------------------------------------------------------------------- 1 | {% extends 'admin/base.html' %} 2 | 3 | {% block title %}Upload File{% endblock %} 4 | {% block page_content %} 5 |

Upload File

6 |
7 | {{ form.hidden_tag() }} 8 | File: {{ form.file }}
9 | 10 |
11 | {% endblock %} -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | alembic==1.2.1 2 | blinker==1.4 3 | Click==7.0 4 | Flask==1.1.1 5 | Flask-Mail==0.9.1 6 | Flask-Migrate==2.5.2 7 | Flask-SQLAlchemy==2.4.1 8 | Flask-WTF==0.14.2 9 | itsdangerous==1.1.0 10 | Jinja2==2.10.3 11 | Mako==1.1.0 12 | MarkupSafe==1.1.1 13 | PyMySQL==0.9.3 14 | python-dateutil==2.8.0 15 | python-dotenv==0.10.3 16 | python-editor==1.0.4 17 | redis==3.3.11 18 | six==1.12.0 19 | SQLAlchemy==1.3.10 20 | Werkzeug==0.16.0 21 | WTForms==2.2.1 22 | -------------------------------------------------------------------------------- /templates/blog/single_category.html: -------------------------------------------------------------------------------- 1 | {% extends 'blog/base.html' %} 2 | 3 | {% block title %}{{ category_name }} Category{% endblock %} 4 | 5 | 6 | {% block page_content %} 7 |

{{ category_name }} Category

8 | {% for post in posts %} 9 |
10 |

{{ post.title }}

11 |

{{ post.summary or post.content | truncate(64, true, '...')}}

12 |
13 | {% endfor %} 14 | {% endblock %} 15 | -------------------------------------------------------------------------------- /templates/blog/search.html: -------------------------------------------------------------------------------- 1 | {% extends 'blog/base.html' %} 2 | 3 | {% block title %}Search for '{{ search_query | safe }}'{% endblock %} 4 | 5 | 6 | {% block page_content %} 7 |

Results for '{{ search_query }}'

8 | {% for post in posts %} 9 |
10 |

{{ post.title }}

11 |

{{ post.summary or post.content | truncate(64, true, '...')}}

12 |
13 | {% endfor %} 14 | {% endblock %} 15 | -------------------------------------------------------------------------------- /templates/admin/create_category.html: -------------------------------------------------------------------------------- 1 | {% extends 'admin/base.html' %} 2 | 3 | {% block title %}Create a new category{% endblock %} 4 | {% block page_content %} 5 |

Create a new category

6 |
7 | {{ form.hidden_tag() }} 8 | Name: {{ form.name }}
9 | Slug: {{ form.slug }}
10 | Description: {{ form.description }}
11 | 12 |
13 | {% endblock %} -------------------------------------------------------------------------------- /templates/admin/modify_category.html: -------------------------------------------------------------------------------- 1 | {% extends 'admin/base.html' %} 2 | 3 | {% block title %}Modify {{category.name}}{% endblock %} 4 | 5 | {% block page_content %} 6 |

{{ category.name }}

7 |
8 | {{ form.hidden_tag() }} 9 | Name: {{ form.name }}
10 | Slug: {{ form.slug }}
11 | Description: {{ form.description }}
12 | 13 |
14 | {% endblock %} -------------------------------------------------------------------------------- /templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {% block title %}{% endblock %} 5 | 6 | 7 | 8 | 9 | {% block content %} 10 | {% endblock %} 11 | 12 | -------------------------------------------------------------------------------- /templates/blog/_includes/navigation.html: -------------------------------------------------------------------------------- 1 |
2 | Register 3 | Blog 4 |
5 |
6 | Python Logo 7 |
8 |
9 |
10 | {{ search_form.search_query }} 11 | 12 |
13 |
-------------------------------------------------------------------------------- /templates/admin/base.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block content %} 4 | {% include 'admin/_includes/navigation.html' %} 5 | {% with messages = get_flashed_messages(True) %} 6 | {% if messages %} 7 |
8 | 9 | {% for message in messages %} 10 |
11 |
12 |

{{ message[1] }}

13 |
14 |
15 | {% endfor %} 16 |
17 | {% endif %} 18 | {% endwith %} 19 | {% block page_content %} 20 | {% endblock %} 21 | {% endblock %} -------------------------------------------------------------------------------- /templates/admin/create_post.html: -------------------------------------------------------------------------------- 1 | {% extends 'admin/base.html' %} 2 | 3 | {% block title %}Create a new post{% endblock %} 4 | {% block page_content %} 5 |

Create a new post

6 |
7 | {{ form.hidden_tag() }} 8 | Title: {{ form.title }}
9 | Slug: {{ form.slug }}
10 | Summary: {{ form.summary }}
11 | Content: {{ form.content }}
12 | Categories: {{ form.categories }}
13 | 14 |
15 | {% endblock %} -------------------------------------------------------------------------------- /templates/admin/modify_post.html: -------------------------------------------------------------------------------- 1 | {% extends 'admin/base.html' %} 2 | 3 | {% block title %}Modify {{post.title}}{% endblock %} 4 | 5 | {% block page_content %} 6 |

{{ post.title }}

7 |
8 | {{ form.hidden_tag() }} 9 | Title: {{ form.title }}
10 | Slug: {{ form.slug }}
11 | Summary: {{ form.summary }}
12 | Content: {{ form.content }}
13 | Categories: {{ form.categories }}
14 | 15 |
16 | {% endblock %} -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | class Config: 5 | SQLALCHEMY_DATABASE_URI = os.getenv('SQLALCHEMY_DATABASE_URI') 6 | SQLALCHEMY_TRACK_MODIFICATIONS = os.getenv('SQLALCHEMY_TRACK_MODIFICATIONS', False) 7 | SECRET_KEY = os.getenv('SECRET_KEY') 8 | MAIL_SERVER = os.getenv('MAIL_SERVER') 9 | MAIL_USERNAME = os.getenv('MAIL_USERNAME') 10 | MAIL_PASSWORD = os.getenv('MAIL_PASSWORD') 11 | REDIS_SERVER_URL = os.getenv('REDIS_SERVER_URL') 12 | 13 | 14 | class Development(Config): 15 | DEBUG = True 16 | 17 | 18 | class Production(Config): 19 | DEBUG = False 20 | -------------------------------------------------------------------------------- /migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade(): 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade(): 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /mod_users/forms.py: -------------------------------------------------------------------------------- 1 | from flask_wtf import FlaskForm 2 | from wtforms.fields.html5 import EmailField 3 | from wtforms import PasswordField, StringField 4 | from wtforms.validators import DataRequired 5 | 6 | 7 | class LoginForm(FlaskForm): 8 | email = EmailField(validators=[DataRequired()]) 9 | password = PasswordField(validators=[DataRequired()]) 10 | 11 | 12 | class RegisterForm(FlaskForm): 13 | full_name = StringField() 14 | email = EmailField(validators=[DataRequired()]) 15 | password = PasswordField(validators=[DataRequired()]) 16 | confirm_password = PasswordField(validators=[DataRequired()]) 17 | -------------------------------------------------------------------------------- /templates/blog/index.html: -------------------------------------------------------------------------------- 1 | {% extends 'blog/base.html' %} 2 | 3 | {% block title %}Blog{% endblock %} 4 | 5 | 6 | {% block page_content %} 7 |

Blog

8 | {% for post in posts.items %} 9 |
10 |

{{ post.title }}

11 |

{{ post.summary or post.content | truncate(64, true, '...')}}

12 |
13 | {% endfor %} 14 | 15 | {% for page in range(1, posts.pages+1) %} 16 | {{ page }} 17 | {% endfor %} 18 | 19 | {% endblock %} 20 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | from flask import Flask 2 | from flask_sqlalchemy import SQLAlchemy 3 | from flask_migrate import Migrate 4 | from flask_mail import Mail 5 | from redis import Redis 6 | from config import Development 7 | 8 | 9 | app = Flask(__name__) 10 | app.config.from_object(Development) 11 | 12 | db = SQLAlchemy(app) 13 | migrate = Migrate(app, db) 14 | mail = Mail(app) 15 | redis = Redis.from_url(app.config['REDIS_SERVER_URL']) 16 | 17 | from views import index 18 | 19 | from mod_admin import admin 20 | from mod_users import users 21 | from mod_blog import blog 22 | from mod_uploads import uploads 23 | 24 | app.register_blueprint(admin) 25 | app.register_blueprint(users) 26 | app.register_blueprint(blog) 27 | app.register_blueprint(uploads) -------------------------------------------------------------------------------- /templates/admin/_includes/navigation.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | List Posts 4 | Create Post 5 | List Categories 6 | Create Category 7 | List Users 8 | Create User 9 | Logout 10 |
-------------------------------------------------------------------------------- /templates/admin/list_users.html: -------------------------------------------------------------------------------- 1 | {% extends 'admin/base.html' %} 2 | 3 | {% block title %}Users List - Admin Dashboard{% endblock %} 4 | 5 | {% block page_content %} 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | {% for user in users %} 18 | 19 | 20 | 21 | 22 | 23 | 24 | {% endfor %} 25 | 26 |
Users
IDFullnameEmailRole
{{ user.id }}{{ user.fullname or '-' }}{{ user.email }}{{ 'Admin' if user.role else 'User' }}
27 | {% endblock %} -------------------------------------------------------------------------------- /mod_blog/forms.py: -------------------------------------------------------------------------------- 1 | from flask_wtf import FlaskForm 2 | from wtforms import TextField, TextAreaField 3 | from wtforms.validators import DataRequired 4 | from utils.forms import MultipleCheckboxField 5 | 6 | 7 | class PostForm(FlaskForm): 8 | title = TextField(validators=[DataRequired()]) 9 | summary = TextAreaField() 10 | content = TextAreaField(validators=[DataRequired()]) 11 | slug = TextField(validators=[DataRequired()]) 12 | categories = MultipleCheckboxField(coerce=int) 13 | 14 | 15 | class CategoryForm(FlaskForm): 16 | name = TextField(validators=[DataRequired()]) 17 | slug = TextField(validators=[DataRequired()]) 18 | description = TextAreaField() 19 | 20 | 21 | class SearchForm(FlaskForm): 22 | search_query = TextField(validators=[DataRequired()]) 23 | -------------------------------------------------------------------------------- /migrations/versions/c1d2f0f92245_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: c1d2f0f92245 4 | Revises: a70d7f84a0c8 5 | Create Date: 2019-12-14 15:16:50.072125 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'c1d2f0f92245' 14 | down_revision = 'a70d7f84a0c8' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.add_column('users', sa.Column('active', sa.Boolean(), nullable=True)) 22 | # ### end Alembic commands ### 23 | 24 | 25 | def downgrade(): 26 | # ### commands auto generated by Alembic - please adjust! ### 27 | op.drop_column('users', 'active') 28 | # ### end Alembic commands ### 29 | -------------------------------------------------------------------------------- /templates/admin/login.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block title %}Admin Login{% endblock %} 4 | 5 | {% block content %} 6 |

Admin Login

7 |
8 | {% with messages = get_flashed_messages(True) %} 9 | {% if messages %} 10 |
11 | 12 | {% for message in messages %} 13 |
14 |
15 |

{{ message[1] }}

16 |
17 |
18 | {% endfor %} 19 |
20 | {% endif %} 21 | {% endwith %} 22 | {{ form.hidden_tag() }} 23 | Email: {{ form.email }}
24 | Password: {{ form.password }}
25 | 26 |
27 | {% endblock %} -------------------------------------------------------------------------------- /mod_users/models.py: -------------------------------------------------------------------------------- 1 | from werkzeug.security import generate_password_hash, check_password_hash 2 | from sqlalchemy import Column, Integer, String, Boolean 3 | from app import db 4 | 5 | 6 | class User(db.Model): 7 | __tablename__ = 'users' 8 | id = Column(Integer(), primary_key=True) 9 | email = Column(String(128), nullable=False, unique=True) 10 | password = Column(String(128), nullable=False, unique=False) 11 | role = Column(Integer(), nullable=False, default=0) 12 | full_name = Column(String(128), nullable=True, unique=False) 13 | active = Column(Boolean, nullable=False, unique=False, default=False) 14 | 15 | def set_password(self, password): 16 | self.password = generate_password_hash(password) 17 | 18 | def check_password(self, password): 19 | return check_password_hash(self.password, password) 20 | 21 | def is_admin(self): 22 | return self.role == 1 23 | -------------------------------------------------------------------------------- /mod_users/utils.py: -------------------------------------------------------------------------------- 1 | import random 2 | from flask import url_for 3 | from app import redis, mail 4 | 5 | 6 | def add_to_redis(user, mode): 7 | token = random.randint(10000, 99999) 8 | name = f'{user.id}_{mode.lower()}' 9 | redis.set(name=name, value=token, ex=14400) 10 | return token 11 | 12 | 13 | def get_from_redis(user, mode): 14 | name = f'{user.id}_{mode.lower()}' 15 | return redis.get(name=name) 16 | 17 | 18 | def delete_from_redis(user, mode): 19 | name = f'{user.id}_{mode.lower()}' 20 | redis.delete(name) 21 | 22 | 23 | def send_signup_message(user, token): 24 | url = url_for('users.confirm_registeration', email=user.email, token=token, _external=True) 25 | 26 | sender = 'flasktuts@ayinmehr.ir' 27 | recipients = [user.email] 28 | subject = 'Flask Blog - Registeration Confirm' 29 | body = f'Hello,
Open this URL: {url}.' 30 | mail.send_message(sender=sender, recipients=recipients, subject=subject, html=body) 31 | -------------------------------------------------------------------------------- /migrations/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # template used to generate migration files 5 | # file_template = %%(rev)s_%%(slug)s 6 | 7 | # set to 'true' to run the environment during 8 | # the 'revision' command, regardless of autogenerate 9 | # revision_environment = false 10 | 11 | 12 | # Logging configuration 13 | [loggers] 14 | keys = root,sqlalchemy,alembic 15 | 16 | [handlers] 17 | keys = console 18 | 19 | [formatters] 20 | keys = generic 21 | 22 | [logger_root] 23 | level = WARN 24 | handlers = console 25 | qualname = 26 | 27 | [logger_sqlalchemy] 28 | level = WARN 29 | handlers = 30 | qualname = sqlalchemy.engine 31 | 32 | [logger_alembic] 33 | level = INFO 34 | handlers = 35 | qualname = alembic 36 | 37 | [handler_console] 38 | class = StreamHandler 39 | args = (sys.stderr,) 40 | level = NOTSET 41 | formatter = generic 42 | 43 | [formatter_generic] 44 | format = %(levelname)-5.5s [%(name)s] %(message)s 45 | datefmt = %H:%M:%S 46 | -------------------------------------------------------------------------------- /migrations/versions/a363462cb5a4_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: a363462cb5a4 4 | Revises: c1d2f0f92245 5 | Create Date: 2019-12-14 15:17:28.249317 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | from sqlalchemy.dialects import mysql 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'a363462cb5a4' 14 | down_revision = 'c1d2f0f92245' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.alter_column('users', 'active', 22 | existing_type=mysql.TINYINT(display_width=1), 23 | nullable=False) 24 | # ### end Alembic commands ### 25 | 26 | 27 | def downgrade(): 28 | # ### commands auto generated by Alembic - please adjust! ### 29 | op.alter_column('users', 'active', 30 | existing_type=mysql.TINYINT(display_width=1), 31 | nullable=True) 32 | # ### end Alembic commands ### 33 | -------------------------------------------------------------------------------- /migrations/versions/a70d7f84a0c8_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: a70d7f84a0c8 4 | Revises: c2376ca71a2c 5 | Create Date: 2019-12-05 18:42:39.813923 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'a70d7f84a0c8' 14 | down_revision = 'c2376ca71a2c' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('file', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('filename', sa.String(length=256), nullable=False), 24 | sa.Column('upload_date', sa.DateTime(), nullable=False), 25 | sa.PrimaryKeyConstraint('id'), 26 | sa.UniqueConstraint('filename') 27 | ) 28 | # ### end Alembic commands ### 29 | 30 | 31 | def downgrade(): 32 | # ### commands auto generated by Alembic - please adjust! ### 33 | op.drop_table('file') 34 | # ### end Alembic commands ### 35 | -------------------------------------------------------------------------------- /templates/admin/list_posts.html: -------------------------------------------------------------------------------- 1 | {% extends 'admin/base.html' %} 2 | 3 | {% block title %}Posts List - Admin Dashboard{% endblock %} 4 | 5 | {% block page_content %} 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | {% for post in posts %} 17 | 18 | 19 | 22 | 26 | 27 | {% endfor %} 28 | 29 |
Posts
IDTitleActions
{{ post.id }} 20 | {{ post.title }} 21 | 23 | Delete 24 | Modify 25 |
30 | {% endblock %} -------------------------------------------------------------------------------- /migrations/versions/c2376ca71a2c_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: c2376ca71a2c 4 | Revises: 96afae1f7fd7 5 | Create Date: 2019-11-08 05:39:57.182125 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'c2376ca71a2c' 14 | down_revision = '96afae1f7fd7' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('posts_categories', 22 | sa.Column('post_id', sa.Integer(), nullable=True), 23 | sa.Column('category_id', sa.Integer(), nullable=True), 24 | sa.ForeignKeyConstraint(['category_id'], ['categories.id'], ondelete='cascade'), 25 | sa.ForeignKeyConstraint(['post_id'], ['posts.id'], ondelete='cascade') 26 | ) 27 | # ### end Alembic commands ### 28 | 29 | 30 | def downgrade(): 31 | # ### commands auto generated by Alembic - please adjust! ### 32 | op.drop_table('posts_categories') 33 | # ### end Alembic commands ### 34 | -------------------------------------------------------------------------------- /templates/admin/list_categories.html: -------------------------------------------------------------------------------- 1 | {% extends 'admin/base.html' %} 2 | 3 | {% block title %}Categories List - Admin Dashboard{% endblock %} 4 | 5 | {% block page_content %} 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | {% for category in categories %} 17 | 18 | 19 | 22 | 26 | 27 | {% endfor %} 28 | 29 |
Categories
IDNameActions
{{ category.id }} 20 | {{ category.name }} 21 | 23 | Delete 24 | Modify 25 |
30 | {% endblock %} -------------------------------------------------------------------------------- /migrations/versions/4f0b9f08febc_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 4f0b9f08febc 4 | Revises: 5 | Create Date: 2019-10-24 04:16:36.484809 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '4f0b9f08febc' 14 | down_revision = None 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('user', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('email', sa.String(length=128), nullable=False), 24 | sa.Column('password', sa.String(length=128), nullable=False), 25 | sa.Column('role', sa.Integer(), nullable=False), 26 | sa.Column('full_name', sa.String(length=128), nullable=True), 27 | sa.PrimaryKeyConstraint('id'), 28 | sa.UniqueConstraint('email') 29 | ) 30 | # ### end Alembic commands ### 31 | 32 | 33 | def downgrade(): 34 | # ### commands auto generated by Alembic - please adjust! ### 35 | op.drop_table('user') 36 | # ### end Alembic commands ### 37 | -------------------------------------------------------------------------------- /mod_blog/models.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column, Integer, String, Text, Table, ForeignKey 2 | from app import db 3 | 4 | 5 | posts_categories = Table('posts_categories', db.metadata, 6 | Column('post_id', Integer, ForeignKey('posts.id', ondelete='cascade')), 7 | Column('category_id', Integer, ForeignKey('categories.id', ondelete='cascade')) 8 | ) 9 | 10 | 11 | class Category(db.Model): 12 | __tablename__ = 'categories' 13 | id = Column(Integer, primary_key=True) 14 | name = Column(String(128), nullable=False, unique=True) 15 | description = Column(String(256), nullable=True, unique=False) 16 | slug = Column(String(128), nullable=False, unique=True) 17 | posts = db.relationship('Post', secondary=posts_categories, back_populates='categories') 18 | 19 | 20 | class Post(db.Model): 21 | __tablename__ = 'posts' 22 | id = Column(Integer, primary_key=True) 23 | title = Column(String(128), nullable=False, unique=True) 24 | summary = Column(String(256), nullable=True, unique=False) 25 | content = Column(Text, nullable=False, unique=False) 26 | slug = Column(String(128), nullable=False, unique=True) 27 | categories = db.relationship('Category', secondary=posts_categories, back_populates='posts') 28 | -------------------------------------------------------------------------------- /templates/admin/create_user.html: -------------------------------------------------------------------------------- 1 | {% extends 'admin/base.html' %} 2 | 3 | {% block title %}Create User - Admin Dashboard{% endblock %} 4 | 5 | {% block page_content %} 6 |

Register

7 | 8 |
9 | {{ form.hidden_tag() }} 10 | Full Name: {{ form.full_name }} 11 | {% if form.full_name.errors %} 12 | 17 | {% endif %} 18 |
19 | Email: {{ form.email }} 20 | {% if form.email.errors %} 21 | 26 | {% endif %} 27 |
28 | Password: {{ form.password }} 29 | {% if form.password.errors %} 30 | 35 | {% endif %}
36 | Confirm Password: {{ form.confirm_password }} 37 | {% if form.confirm_password.errors %} 38 | 43 | {% endif %}
44 | 45 |
46 | {% endblock %} -------------------------------------------------------------------------------- /migrations/versions/96afae1f7fd7_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: 96afae1f7fd7 4 | Revises: a967f45ee27d 5 | Create Date: 2019-11-06 14:16:52.207006 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '96afae1f7fd7' 14 | down_revision = 'a967f45ee27d' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('categories', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('name', sa.String(length=128), nullable=False), 24 | sa.Column('description', sa.String(length=256), nullable=True), 25 | sa.Column('slug', sa.String(length=128), nullable=False), 26 | sa.PrimaryKeyConstraint('id'), 27 | sa.UniqueConstraint('name'), 28 | sa.UniqueConstraint('slug') 29 | ) 30 | op.create_table('posts', 31 | sa.Column('id', sa.Integer(), nullable=False), 32 | sa.Column('title', sa.String(length=128), nullable=False), 33 | sa.Column('summary', sa.String(length=256), nullable=True), 34 | sa.Column('content', sa.Text(), nullable=False), 35 | sa.Column('slug', sa.String(length=128), nullable=False), 36 | sa.PrimaryKeyConstraint('id'), 37 | sa.UniqueConstraint('slug'), 38 | sa.UniqueConstraint('title') 39 | ) 40 | # ### end Alembic commands ### 41 | 42 | 43 | def downgrade(): 44 | # ### commands auto generated by Alembic - please adjust! ### 45 | op.drop_table('posts') 46 | op.drop_table('categories') 47 | # ### end Alembic commands ### 48 | -------------------------------------------------------------------------------- /templates/users/register.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block title %}Register{% endblock %} 4 | 5 | {% block content %} 6 |

Register

7 | 8 |
9 | {% with messages = get_flashed_messages(True) %} 10 | {% if messages %} 11 |
12 | 13 | {% for message in messages %} 14 |
15 |
16 |

{{ message[1] }}

17 |
18 |
19 | {% endfor %} 20 |
21 | {% endif %} 22 | {% endwith %} 23 | {{ form.hidden_tag() }} 24 | Full Name: {{ form.full_name }} 25 | {% if form.full_name.errors %} 26 | 31 | {% endif %} 32 |
33 | Email: {{ form.email }} 34 | {% if form.email.errors %} 35 | 40 | {% endif %} 41 |
42 | Password: {{ form.password }} 43 | {% if form.password.errors %} 44 | 49 | {% endif %}
50 | Confirm Password: {{ form.confirm_password }} 51 | {% if form.confirm_password.errors %} 52 | 57 | {% endif %}
58 | 59 |
60 | {% endblock %} -------------------------------------------------------------------------------- /mod_blog/views.py: -------------------------------------------------------------------------------- 1 | from flask import render_template, request 2 | from flask_sqlalchemy import get_debug_queries 3 | from sqlalchemy import or_ 4 | 5 | from . import blog 6 | from .models import Post, Category 7 | from .forms import SearchForm 8 | 9 | @blog.route('/') 10 | def index(): 11 | page = request.args.get('p', default=1, type=int) 12 | search_form = SearchForm() 13 | posts = Post.query.paginate(page, 5) 14 | return render_template('blog/index.html', posts=posts, search_form=search_form) 15 | 16 | 17 | @blog.route('/') 18 | def single_post(slug): 19 | search_form = SearchForm() 20 | post = Post.query.filter(Post.slug == slug).first_or_404() 21 | return render_template('blog/single_post.html', post=post, search_form=search_form) 22 | 23 | 24 | @blog.route('/search') 25 | def search_blog(): 26 | search_form = SearchForm() 27 | search_query = request.args.get('search_query', '') 28 | title_cond = Post.title.ilike(f'%{search_query}%') 29 | summary_cond = Post.summary.ilike(f'%{search_query}%') 30 | content_cond = Post.content.ilike(f'%{search_query}%') 31 | found_posts = Post.query.filter(or_(title_cond, 32 | summary_cond, 33 | content_cond)).all() 34 | print(found_posts) 35 | return render_template('blog/search.html', posts=found_posts, search_form=search_form, search_query=search_query) 36 | 37 | 38 | @blog.route('/category/') 39 | def single_category(slug): 40 | search_form = SearchForm() 41 | category = Category.query.filter(Category.slug == slug).first_or_404() 42 | return render_template('blog/single_category.html', posts=category.posts, search_form=search_form, category_name=category.name) 43 | -------------------------------------------------------------------------------- /migrations/versions/a967f45ee27d_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: a967f45ee27d 4 | Revises: 4f0b9f08febc 5 | Create Date: 2019-10-24 04:19:50.590257 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | from sqlalchemy.dialects import mysql 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'a967f45ee27d' 14 | down_revision = '4f0b9f08febc' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('users', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('email', sa.String(length=128), nullable=False), 24 | sa.Column('password', sa.String(length=128), nullable=False), 25 | sa.Column('role', sa.Integer(), nullable=False), 26 | sa.Column('full_name', sa.String(length=128), nullable=True), 27 | sa.PrimaryKeyConstraint('id'), 28 | sa.UniqueConstraint('email') 29 | ) 30 | op.drop_index('email', table_name='user') 31 | op.drop_table('user') 32 | # ### end Alembic commands ### 33 | 34 | 35 | def downgrade(): 36 | # ### commands auto generated by Alembic - please adjust! ### 37 | op.create_table('user', 38 | sa.Column('id', mysql.INTEGER(display_width=11), autoincrement=True, nullable=False), 39 | sa.Column('email', mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=128), nullable=False), 40 | sa.Column('password', mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=128), nullable=False), 41 | sa.Column('role', mysql.INTEGER(display_width=11), autoincrement=False, nullable=False), 42 | sa.Column('full_name', mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=128), nullable=True), 43 | sa.PrimaryKeyConstraint('id'), 44 | mysql_collate='utf8mb4_unicode_ci', 45 | mysql_default_charset='utf8mb4', 46 | mysql_engine='InnoDB' 47 | ) 48 | op.create_index('email', 'user', ['email'], unique=True) 49 | op.drop_table('users') 50 | # ### end Alembic commands ### 51 | -------------------------------------------------------------------------------- /mod_users/views.py: -------------------------------------------------------------------------------- 1 | from flask import request, render_template, flash 2 | from sqlalchemy.exc import IntegrityError 3 | 4 | from app import db 5 | 6 | from . import users 7 | from .forms import RegisterForm 8 | from .models import User 9 | from .utils import add_to_redis, send_signup_message, get_from_redis, delete_from_redis 10 | 11 | 12 | @users.route('/register/', methods=['GET', 'POST']) 13 | def register(): 14 | form = RegisterForm(request.form) 15 | if request.method == 'POST': 16 | if not form.validate_on_submit(): 17 | return render_template('users/register.html', form=form) 18 | if not form.password.data == form.confirm_password.data: 19 | error_msg = 'Password and Confirm Password does not match.' 20 | form.password.errors.append(error_msg) 21 | form.confirm_password.errors.append(error_msg) 22 | return render_template('users/register.html', form=form) 23 | new_user = User() 24 | new_user.full_name = form.full_name.data 25 | new_user.email = form.email.data 26 | new_user.set_password(form.password.data) 27 | try: 28 | db.session.add(new_user) 29 | db.session.commit() 30 | token = add_to_redis(new_user, 'register') 31 | send_signup_message(new_user, token) 32 | flash('You created your account successfully.', 'success') 33 | except IntegrityError: 34 | db.session.rollback() 35 | flash('Email is in use.', 'error') 36 | return render_template('users/register.html', form=form) 37 | 38 | 39 | @users.route('/confirm/') 40 | def confirm_registeration(): 41 | email = request.args.get('email') 42 | token = request.args.get('token') 43 | print(email, token) 44 | 45 | user = User.query.filter(User.email.ilike(email)).first() 46 | if not user: 47 | return "User not found!" 48 | if user.active: 49 | return "User already activated!" 50 | 51 | token_from_redis = get_from_redis(user, 'register') 52 | if not token_from_redis: 53 | return "Wrong/Expired Token!" 54 | if token != token_from_redis.decode('UTF-8'): 55 | return "Wrong/Expired Token!" 56 | 57 | user.active = True 58 | db.session.commit() 59 | delete_from_redis(user, 'register') 60 | 61 | return "1" 62 | -------------------------------------------------------------------------------- /Relation_Examples.md: -------------------------------------------------------------------------------- 1 | # Relations 2 | 3 | The character `*` means `UNIQUE` 4 | 5 | # One To Many (Many to One) 6 | 7 | ### Authors 8 | 9 | | id\* | full_name\* | 10 | | :--: | :----------------: | 11 | | 1 | Brian Tracy | 12 | | 2 | Robert T. Kiyosaki | 13 | | 3 | Rolf Dobelli | 14 | | 4 | Jane Austen | 15 | 16 | ### Books 17 | 18 | | id\* | book_name | author_id (`authors.id`) | 19 | | :--: | :------------------------------: | :----------------------: | 20 | | 1 | The Art of the Good Life | 3 | 21 | | 2 | Rich Dad, Poor Dad | 2 | 22 | | 3 | Time Management | 1 | 23 | | 4 | Pride and Prejudice | 4 | 24 | | 5 | The Business of the 21st Century | 2 | 25 | | 6 | Sense and Sensibility | 4 | 26 | | 7 | Emma | 4 | 27 | | 8 | The Psychology of Achievement | 1 | 28 | 29 | # One To One 30 | 31 | ### Teachers 32 | 33 | | id\* | full_name\* | 34 | | :--: | :-----------: | 35 | | 1 | John Doe | 36 | | 2 | Dudley Taylan | 37 | | 3 | Jamal Detrick | 38 | | 4 | Fannie Warntz | 39 | 40 | ### Classes 41 | 42 | | id\* | name | teacher_id\* | 43 | | :--: | :----: | :----------: | 44 | | 1 | ABC | 3 | 45 | | 2 | DEF | 4 | 46 | | 3 | QWERTY | 2 | 47 | | 4 | ASDFGH | 1 | 48 | 49 | # Many to Many 50 | 51 | ### Categories 52 | 53 | | id\* | category_name\* | 54 | | :--: | :-------------: | 55 | | 1 | Programming | 56 | | 2 | Media | 57 | | 3 | Graphics | 58 | | 4 | News | 59 | 60 | ### Posts 61 | 62 | | id\* | title | 63 | | :--: | :----------------------------------: | 64 | | 1 | How to use Flask? | 65 | | 2 | Python's author is resigning!! | 66 | | 3 | Uploading Media on Flask | 67 | | 4 | Version Control System for Designers | 68 | 69 | ### Posts_Categories (Association Table) 70 | 71 | **`post_id` + `category_id` should be unique next to each other!!*** 72 | 73 | | post_id | category_id | 74 | | :-----: | :---------: | 75 | | 1 | 1 | 76 | | 2 | 1 | 77 | | 2 | 4 | 78 | | 3 | 1 | 79 | | 3 | 2 | 80 | | 4 | 3 | 81 | -------------------------------------------------------------------------------- /migrations/env.py: -------------------------------------------------------------------------------- 1 | from __future__ import with_statement 2 | 3 | import logging 4 | from logging.config import fileConfig 5 | 6 | from sqlalchemy import engine_from_config 7 | from sqlalchemy import pool 8 | 9 | from alembic import context 10 | 11 | # this is the Alembic Config object, which provides 12 | # access to the values within the .ini file in use. 13 | config = context.config 14 | 15 | # Interpret the config file for Python logging. 16 | # This line sets up loggers basically. 17 | fileConfig(config.config_file_name) 18 | logger = logging.getLogger('alembic.env') 19 | 20 | # add your model's MetaData object here 21 | # for 'autogenerate' support 22 | # from myapp import mymodel 23 | # target_metadata = mymodel.Base.metadata 24 | from flask import current_app 25 | config.set_main_option( 26 | 'sqlalchemy.url', current_app.config.get( 27 | 'SQLALCHEMY_DATABASE_URI').replace('%', '%%')) 28 | target_metadata = current_app.extensions['migrate'].db.metadata 29 | 30 | # other values from the config, defined by the needs of env.py, 31 | # can be acquired: 32 | # my_important_option = config.get_main_option("my_important_option") 33 | # ... etc. 34 | 35 | 36 | def run_migrations_offline(): 37 | """Run migrations in 'offline' mode. 38 | 39 | This configures the context with just a URL 40 | and not an Engine, though an Engine is acceptable 41 | here as well. By skipping the Engine creation 42 | we don't even need a DBAPI to be available. 43 | 44 | Calls to context.execute() here emit the given string to the 45 | script output. 46 | 47 | """ 48 | url = config.get_main_option("sqlalchemy.url") 49 | context.configure( 50 | url=url, target_metadata=target_metadata, literal_binds=True 51 | ) 52 | 53 | with context.begin_transaction(): 54 | context.run_migrations() 55 | 56 | 57 | def run_migrations_online(): 58 | """Run migrations in 'online' mode. 59 | 60 | In this scenario we need to create an Engine 61 | and associate a connection with the context. 62 | 63 | """ 64 | 65 | # this callback is used to prevent an auto-migration from being generated 66 | # when there are no changes to the schema 67 | # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html 68 | def process_revision_directives(context, revision, directives): 69 | if getattr(config.cmd_opts, 'autogenerate', False): 70 | script = directives[0] 71 | if script.upgrade_ops.is_empty(): 72 | directives[:] = [] 73 | logger.info('No changes in schema detected.') 74 | 75 | connectable = engine_from_config( 76 | config.get_section(config.config_ini_section), 77 | prefix='sqlalchemy.', 78 | poolclass=pool.NullPool, 79 | ) 80 | 81 | with connectable.connect() as connection: 82 | context.configure( 83 | connection=connection, 84 | target_metadata=target_metadata, 85 | process_revision_directives=process_revision_directives, 86 | **current_app.extensions['migrate'].configure_args 87 | ) 88 | 89 | with context.begin_transaction(): 90 | context.run_migrations() 91 | 92 | 93 | if context.is_offline_mode(): 94 | run_migrations_offline() 95 | else: 96 | run_migrations_online() 97 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FlaskBlog 2 | The first project of my Flask Tuts Series in Persian! 3 | We are going to develop a blog using Flask, Each episode, has it's own branch 4 | 5 | ## Episodes 6 | 0. Project Directory | [Branch on Github](https://github.com/DarkSuniuM/FlaskBlog/tree/00-Project_Directory) | [Video On YouTube](https://youtu.be/wYYLs_yqJ_8) 7 | 0. Admin Blueprint | [Branch on Github](https://github.com/DarkSuniuM/FlaskBlog/tree/01-Admin_Blueprint) | [Video On YouTube](https://youtu.be/dPB5N1Uk_Ik) 8 | 0. Users Model | [Branch On Github](https://github.com/DarkSuniuM/FlaskBlog/tree/02-Users_Model) | [Video On YouTube](https://youtu.be/GNHlcw4yQjI) 9 | 0. Storing Passwords | [Branch On Github](https://github.com/DarkSuniuM/FlaskBlog/tree/03-Storing_Passwords) | [Video On YouTube](https://youtu.be/RENMu08Gt5Y) 10 | 0. Sessions | [Branch On Github](https://github.com/DarkSuniuM/FlaskBlog/tree/04-Sessions) | [Video On YouTube](https://youtu.be/yx2KnB3msZE) 11 | 0. Admin Login Page | [Branch On Github](https://github.com/DarkSuniuM/FlaskBlog/tree/05-Admin_Login_Page) | [Video On YouTube](https://youtu.be/nS6PnTu1XrQ) 12 | 0. Inheriting/Extending Templates | [Branch On Github](https://github.com/DarkSuniuM/FlaskBlog/tree/06-Inheriting/Extending_Templates) | [Video On YouTube](https://youtu.be/IPgkm-CgArE) 13 | 0. Message Flashing | [Branch On Github](https://github.com/DarkSuniuM/FlaskBlog/tree/07-Message_Flashing) | [Video On YouTube](https://youtu.be/su4fQXjLTwI) 14 | 0. PermissionBased Views | [Branch On Github](https://github.com/DarkSuniuM/FlaskBlog/tree/08-PermissionBased_Views) | [Video On YouTube](https://youtu.be/7mFvYbRQ6ec) 15 | 0. Showing Form Errors | [Branch On Github](https://github.com/DarkSuniuM/FlaskBlog/tree/09-Showing_Form_Errors) | [Video On YouTube](https://youtu.be/GUI9BWCG-qc) 16 | 0. Handling SQLAlchemy Errors | [Branch On Github](https://github.com/DarkSuniuM/FlaskBlog/tree/10-Handling_SQLAlchemy_Errors) | [Video On YouTube](https://youtu.be/eAttPLeMFsU) 17 | 0. Database Relations | [Branch On Github](https://github.com/DarkSuniuM/FlaskBlog/tree/11-Database_Relations) | [Video On YouTube](https://youtu.be/Jnfg47tqAMk) 18 | 0. Relations in SQLAlchemy | [Branch On Github](https://github.com/DarkSuniuM/FlaskBlog/tree/12-Relations_in_SQLAlchemy) | [Video On YouTube](https://youtu.be/mwB_4biOhHc) 19 | 0. Redirect in Flask, Create New Post Page | [Branch On Github](https://github.com/DarkSuniuM/FlaskBlog/tree/13-Create_New_Post_Page) | [Video On YouTube](https://youtu.be/GsuxVSigdNQ) 20 | 0. Showing blog posts | [Branch On Github](https://github.com/DarkSuniuM/FlaskBlog/tree/14-Showing_blog_posts) | [Video On YouTube](https://youtu.be/CuLAI_zFPvE) 21 | 0. Modify database data, More Admin Panel Pages. | [Branch On Github](https://github.com/DarkSuniuM/FlaskBlog/tree/15-Modify_database_data) | [Video On YouTube](https://youtu.be/pMIFBuTtlkQ) 22 | 0. Add Category {Create, List, Modify, Delete} Pages. | [Branch On Github](https://github.com/DarkSuniuM/FlaskBlog/tree/16-Create_Category_Pages) | [Video On YouTube](https://youtu.be/oD8_br3pwQM) 23 | 0. Customized WTForms Field | [Branch On Github](https://github.com/DarkSuniuM/FlaskBlog/tree/17-Customized_WTForms_Field) | [Video On YouTube](https://youtu.be/8NYPgzPDk3I) 24 | 0. Search ability in posts | [Branch On Github](https://github.com/DarkSuniuM/FlaskBlog/tree/18-Search_ability_in_posts) | [Video On YouTube](https://youtu.be/uVhte6nWmuk) 25 | 0. Getting started with static files | [Branch On Github](https://github.com/DarkSuniuM/FlaskBlog/tree/19-Getting_started_with_static) | [Video On YouTube](https://youtu.be/-5CrrhTYc10) 26 | 0. File Uploader | [Branch On Github](https://github.com/DarkSuniuM/FlaskBlog/tree/20-File_Uploader) | [Video On YouTube](https://youtu.be/u2ai7sT5v0o) 27 | 0. Sending mail with Flask | [Branch On Github](https://github.com/DarkSuniuM/FlaskBlog/tree/21-Sending_mail_with_Flask) | [Video On YouTube](https://youtu.be/iqA94dknt3U) 28 | 0. Using Redis for storing tokens in Flask | [Branch On Github](https://github.com/DarkSuniuM/FlaskBlog/tree/22-Using_Redis_for_storing_tokens_in_Flask) | [Video On YouTube](https://youtu.be/Z5fAL3D-2aM) 29 | 0. **Pagination | [Branch On Github](https://github.com/DarkSuniuM/FlaskBlog/tree/23-Pagination) | [Video On YouTube](https://youtu.be/15uoQons8wg)** 30 | 31 | 32 | 33 | 34 | ## Setup and Run 35 | 0. Clone the repo by `$ git clone https://github.com/DarkSuniuM/FlaskBlog.git` 36 | 0. Go to cloned directory and create a virtual environment `$ python3 -m virtualenv venv` or `py -3 -m virtualenv venv` if you are using Windows! 37 | 0. Activate the virtual environment using `$ ./venv/bin/activate` or `$ .\venv\Scripts\activate.bat` if you are using Windows! 38 | 0. Install the requirements using `$ pip install -r requirements.txt` 39 | 0. Copy `.env.example` to `.env` and fill in the keys. 40 | 0. Run the migrations by `$ flask db upgrade` 41 | 0. Run the project using `$ flask run` 42 | 43 | ## The Serie 44 | **[Playlist on YouTube](https://www.youtube.com/playlist?list=PLdUn5H7OTUk1WYCrDJpNGpJ2GFWd7yZaw)** 45 | 46 | Ask your questions on the comments section in YouTube, I try to answer the ones I can! 47 | 48 | 49 | ## Sponcers 50 | 51 | ### HostHey 52 | 53 | You can visit their website: https://hosthey.net 54 | 55 | They gave us a dicount code which you can use for a month of Free Python Shared Host! 56 | 57 | - EP 34 (21 of this project), Provided server to run mail server. 58 | 59 | 60 | ### AzarData 61 | 62 | You can visit their website: https://azardata.net 63 | 64 | They gave us a dicount code which gives you 20% off of the price, it works on everything expect domains! 65 | 66 | - EP 36 (23 of this project), Provided server to run mail server. -------------------------------------------------------------------------------- /mod_admin/views.py: -------------------------------------------------------------------------------- 1 | from flask import (abort, flash, redirect, render_template, request, session, 2 | url_for) 3 | from sqlalchemy.exc import IntegrityError 4 | from werkzeug.utils import secure_filename 5 | import uuid 6 | 7 | 8 | from app import db 9 | from mod_blog.forms import PostForm, CategoryForm 10 | from mod_blog.models import Post, Category 11 | from mod_users.forms import LoginForm, RegisterForm 12 | from mod_users.models import User 13 | from mod_uploads.models import File 14 | from mod_uploads.forms import FileUploadForm 15 | 16 | from . import admin 17 | from .utils import admin_only_view 18 | 19 | 20 | @admin.route('/') 21 | @admin_only_view 22 | def index(): 23 | return render_template('admin/index.html') 24 | 25 | 26 | @admin.route('/login/', methods=['GET', 'POST']) 27 | def login(): 28 | form = LoginForm(request.form) 29 | if request.method == 'POST': 30 | if not form.validate_on_submit(): 31 | abort(400) 32 | user = User.query.filter(User.email.ilike(f'{form.email.data}')).first() 33 | if not user: 34 | flash('Incorrect Credentials', category='error') 35 | return render_template('admin/login.html', form=form) 36 | if not user.check_password(form.password.data): 37 | flash('Incorrect Credentials', category='error') 38 | return render_template('admin/login.html', form=form) 39 | if not user.is_admin(): 40 | flash('Incorrect Credentials', category='error') 41 | return render_template('admin/login.html', form=form) 42 | session['email'] = user.email 43 | session['user_id'] = user.id 44 | session['role'] = user.role 45 | return redirect(url_for('admin.index')) 46 | if session.get('role') == 1: 47 | return redirect(url_for('admin.index')) 48 | return render_template('admin/login.html', form=form) 49 | 50 | 51 | @admin.route('/logout/', methods=['GET']) 52 | @admin_only_view 53 | def logout(): 54 | session.clear() 55 | flash('You logged out successfully.', 'warning') 56 | return redirect(url_for('admin.login')) 57 | 58 | 59 | @admin.route('/users/', methods=['GET']) 60 | @admin_only_view 61 | def list_users(): 62 | users = User.query.order_by(User.id.desc()).all() 63 | return render_template('admin/list_users.html', users=users) 64 | 65 | 66 | @admin.route('/users/new/', methods=['GET']) 67 | @admin_only_view 68 | def get_create_user(): 69 | form = RegisterForm() 70 | return render_template('admin/create_user.html', form=form) 71 | 72 | 73 | @admin.route('/users/new/', methods=['POST']) 74 | @admin_only_view 75 | def post_create_user(): 76 | form = RegisterForm(request.form) 77 | if not form.validate_on_submit(): 78 | return render_template('admin/create_user.html', form=form) 79 | if not form.password.data == form.confirm_password.data: 80 | error_msg = 'Password and Confirm Password does not match.' 81 | form.password.errors.append(error_msg) 82 | form.confirm_password.errors.append(error_msg) 83 | return render_template('admin/create_user.html', form=form) 84 | new_user = User() 85 | new_user.full_name = form.full_name.data 86 | new_user.email = form.email.data 87 | new_user.set_password(form.password.data) 88 | try: 89 | db.session.add(new_user) 90 | db.session.commit() 91 | flash('You created your account successfully.', 'success') 92 | except IntegrityError: 93 | db.session.rollback() 94 | flash('Email is in use.', 'error') 95 | return render_template('admin/create_user.html', form=form) 96 | 97 | 98 | @admin.route('/posts/new/', methods=['GET', 'POST']) 99 | @admin_only_view 100 | def create_post(): 101 | form = PostForm(request.form) 102 | categories = Category.query.order_by(Category.id.asc()).all() 103 | form.categories.choices = [(category.id, category.name) for category in categories] 104 | if request.method == 'POST': 105 | if not form.validate_on_submit(): 106 | return "Form validation error!" 107 | new_post = Post() 108 | new_post.title = form.title.data 109 | new_post.content = form.content.data 110 | new_post.slug = form.slug.data 111 | new_post.summary = form.summary.data 112 | new_post.categories = [Category.query.get(category_id) for category_id in form.categories.data] 113 | try: 114 | db.session.add(new_post) 115 | db.session.commit() 116 | flash('Post created!') 117 | return redirect(url_for('admin.index')) 118 | except IntegrityError: 119 | db.session.rollback() 120 | flash('Slug Duplicated.') 121 | return render_template('admin/create_post.html', form=form) 122 | 123 | 124 | @admin.route('/posts/', methods=['GET']) 125 | @admin_only_view 126 | def list_posts(): 127 | posts = Post.query.order_by(Post.id.desc()).all() 128 | return render_template('admin/list_posts.html', posts=posts) 129 | 130 | 131 | @admin.route('/posts/delete//', methods=['GET']) 132 | @admin_only_view 133 | def delete_post(post_id): 134 | post = Post.query.get_or_404(post_id) 135 | db.session.delete(post) 136 | db.session.commit() 137 | flash('Post Deleted.') 138 | return redirect(url_for('admin.list_posts')) 139 | 140 | 141 | @admin.route('/posts/modify//', methods=['GET', 'POST']) 142 | @admin_only_view 143 | def modify_post(post_id): 144 | post = Post.query.get_or_404(post_id) 145 | form = PostForm(obj=post) 146 | categories = Category.query.order_by(Category.id.asc()).all() 147 | form.categories.choices = [(category.id, category.name) for category in categories] 148 | if request.method != 'POST': 149 | form.categories.data = [category.id for category in post.categories] 150 | if request.method == 'POST': 151 | if not form.validate_on_submit(): 152 | return render_template('admin/modify_post.html', form=form, post=post) 153 | post.title = form.title.data 154 | post.content = form.content.data 155 | post.slug = form.slug.data 156 | post.summary = form.summary.data 157 | post.categories = [Category.query.get(category_id) for category_id in form.categories.data] 158 | try: 159 | db.session.commit() 160 | flash('Post modified!') 161 | except IntegrityError: 162 | db.session.rollback() 163 | flash('Slug Duplicated.') 164 | return render_template('admin/modify_post.html', form=form, post=post) 165 | 166 | 167 | @admin.route('/categories/new/', methods=['GET', 'POST']) 168 | @admin_only_view 169 | def create_category(): 170 | form = CategoryForm(request.form) 171 | if request.method == 'POST': 172 | if not form.validate_on_submit(): 173 | return "1" 174 | new_category = Category() 175 | new_category.name = form.name.data 176 | new_category.slug = form.slug.data 177 | new_category.description = form.description.data 178 | try: 179 | db.session.add(new_category) 180 | db.session.commit() 181 | flash('Category created!') 182 | return redirect(url_for('admin.index')) 183 | except IntegrityError: 184 | db.session.rollback() 185 | flash('Slug Duplicated.') 186 | return render_template('admin/create_category.html', form=form) 187 | 188 | 189 | @admin.route('/categories/', methods=['GET']) 190 | @admin_only_view 191 | def list_categories(): 192 | categories = Category.query.order_by(Category.id.desc()).all() 193 | print(categories) 194 | return render_template('admin/list_categories.html', categories=categories) 195 | 196 | 197 | @admin.route('/categories/delete//', methods=['GET']) 198 | @admin_only_view 199 | def delete_category(category_id): 200 | category = Category.query.get_or_404(category_id) 201 | db.session.delete(category) 202 | db.session.commit() 203 | flash('Category Deleted.') 204 | return redirect(url_for('admin.list_categories')) 205 | 206 | 207 | @admin.route('/categories/modify//', methods=['GET', 'POST']) 208 | @admin_only_view 209 | def modify_category(category_id): 210 | category = Category.query.get_or_404(category_id) 211 | form = CategoryForm(obj=category) 212 | if request.method == 'POST': 213 | if not form.validate_on_submit(): 214 | return render_template('admin/modify_category.html', form=form, category=category) 215 | category.name = form.name.data 216 | category.description = form.description.data 217 | category.slug = form.slug.data 218 | try: 219 | db.session.commit() 220 | flash('Category modified!') 221 | except IntegrityError: 222 | db.session.rollback() 223 | flash('Slug Duplicated.') 224 | return render_template('admin/modify_category.html', form=form, category=category) 225 | 226 | 227 | 228 | @admin.route('/library/upload', methods=['GET', 'POST']) 229 | @admin_only_view 230 | def upload_file(): 231 | form = FileUploadForm() 232 | if request.method == 'POST': 233 | if not form.validate_on_submit(): 234 | return "1" 235 | filename = f'{uuid.uuid1()}_{secure_filename(form.file.data.filename)}' 236 | new_file = File() 237 | new_file.filename = filename 238 | try: 239 | db.session.add(new_file) 240 | db.session.commit() 241 | form.file.data.save(f'static/uploads/{filename}') 242 | flash(f'File Uploaded on {url_for("static", filename="uploads/"+filename, _external=True)}') 243 | except IntegrityError: 244 | db.session.rollback() 245 | flash('Upload failed', 'error') 246 | return render_template('admin/upload_file.html', form=form) 247 | -------------------------------------------------------------------------------- /static/styles/mini-default.min.css: -------------------------------------------------------------------------------- 1 | :root{--fore-color:#111;--secondary-fore-color:#444;--back-color:#f8f8f8;--secondary-back-color:#f0f0f0;--blockquote-color:#f57c00;--pre-color:#1565c0;--border-color:#aaa;--secondary-border-color:#ddd;--heading-ratio:1.19;--universal-margin:.5rem;--universal-padding:.5rem;--universal-border-radius:.125rem;--a-link-color:#0277bd;--a-visited-color:#01579b}html{font-size:16px}a,b,del,em,i,ins,q,span,strong,u{font-size:1em}html,*{font-family:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Ubuntu, "Helvetica Neue", Helvetica, sans-serif;line-height:1.5;-webkit-text-size-adjust:100%}*{font-size:1rem}body{margin:0;color:var(--fore-color);background:var(--back-color)}details{display:block}summary{display:list-item}abbr[title]{border-bottom:none;text-decoration:underline dotted}input{overflow:visible}img{max-width:100%;height:auto}h1,h2,h3,h4,h5,h6{line-height:1.2;margin:calc(1.5 * var(--universal-margin)) var(--universal-margin);font-weight:500}h1 small,h2 small,h3 small,h4 small,h5 small,h6 small{color:var(--secondary-fore-color);display:block;margin-top:-.25rem}h1{font-size:calc(1rem * var(--heading-ratio) * var(--heading-ratio) * var(--heading-ratio) * var(--heading-ratio))}h2{font-size:calc(1rem * var(--heading-ratio) * var(--heading-ratio) * var(--heading-ratio))}h3{font-size:calc(1rem * var(--heading-ratio) * var(--heading-ratio))}h4{font-size:calc(1rem * var(--heading-ratio))}h5{font-size:1rem}h6{font-size:calc(1rem / var(--heading-ratio))}p{margin:var(--universal-margin)}ol,ul{margin:var(--universal-margin);padding-left:calc(2 * var(--universal-margin))}b,strong{font-weight:700}hr{box-sizing:content-box;border:0;line-height:1.25em;margin:var(--universal-margin);height:.0625rem;background:linear-gradient(to right, transparent, var(--border-color) 20%, var(--border-color) 80%, transparent)}blockquote{display:block;position:relative;font-style:italic;color:var(--secondary-fore-color);margin:var(--universal-margin);padding:calc(3 * var(--universal-padding));border:.0625rem solid var(--secondary-border-color);border-left:.375rem solid var(--blockquote-color);border-radius:0 var(--universal-border-radius) var(--universal-border-radius) 0}blockquote:before{position:absolute;top:calc(0rem - var(--universal-padding));left:0;font-family:sans-serif;font-size:3rem;font-weight:700;content:"\201c";color:var(--blockquote-color)}blockquote[cite]:after{font-style:normal;font-size:.75em;font-weight:700;content:"\a— " attr(cite);white-space:pre}code,kbd,pre,samp{font-family:Menlo, Consolas, monospace;font-size:.85em}code{background:var(--secondary-back-color);border-radius:var(--universal-border-radius);padding:calc(var(--universal-padding) / 4) calc(var(--universal-padding) / 2)}kbd{background:var(--fore-color);color:var(--back-color);border-radius:var(--universal-border-radius);padding:calc(var(--universal-padding) / 4) calc(var(--universal-padding) / 2)}pre{overflow:auto;background:var(--secondary-back-color);padding:calc(1.5 * var(--universal-padding));margin:var(--universal-margin);border:.0625rem solid var(--secondary-border-color);border-left:.25rem solid var(--pre-color);border-radius:0 var(--universal-border-radius) var(--universal-border-radius) 0}sup,sub,code,kbd{line-height:0;position:relative;vertical-align:baseline}small,sup,sub,figcaption{font-size:.75em}sup{top:-.5em}sub{bottom:-.25em}figure{margin:var(--universal-margin)}figcaption{color:var(--secondary-fore-color)}a{text-decoration:none}a:link{color:var(--a-link-color)}a:visited{color:var(--a-visited-color)}a:hover,a:focus{text-decoration:underline}.container{margin:0 auto;padding:0 calc(1.5 * var(--universal-padding))}.row{box-sizing:border-box;display:flex;flex:0 1 auto;flex-flow:row wrap}.col-sm,[class^='col-sm-'],[class^='col-sm-offset-'],.row[class*='cols-sm-']>*{box-sizing:border-box;flex:0 0 auto;padding:0 calc(var(--universal-padding) / 2)}.col-sm,.row.cols-sm>*{max-width:100%;flex-grow:1;flex-basis:0}.col-sm-1,.row.cols-sm-1>*{max-width:8.33333%;flex-basis:8.33333%}.col-sm-offset-0{margin-left:0}.col-sm-2,.row.cols-sm-2>*{max-width:16.66667%;flex-basis:16.66667%}.col-sm-offset-1{margin-left:8.33333%}.col-sm-3,.row.cols-sm-3>*{max-width:25%;flex-basis:25%}.col-sm-offset-2{margin-left:16.66667%}.col-sm-4,.row.cols-sm-4>*{max-width:33.33333%;flex-basis:33.33333%}.col-sm-offset-3{margin-left:25%}.col-sm-5,.row.cols-sm-5>*{max-width:41.66667%;flex-basis:41.66667%}.col-sm-offset-4{margin-left:33.33333%}.col-sm-6,.row.cols-sm-6>*{max-width:50%;flex-basis:50%}.col-sm-offset-5{margin-left:41.66667%}.col-sm-7,.row.cols-sm-7>*{max-width:58.33333%;flex-basis:58.33333%}.col-sm-offset-6{margin-left:50%}.col-sm-8,.row.cols-sm-8>*{max-width:66.66667%;flex-basis:66.66667%}.col-sm-offset-7{margin-left:58.33333%}.col-sm-9,.row.cols-sm-9>*{max-width:75%;flex-basis:75%}.col-sm-offset-8{margin-left:66.66667%}.col-sm-10,.row.cols-sm-10>*{max-width:83.33333%;flex-basis:83.33333%}.col-sm-offset-9{margin-left:75%}.col-sm-11,.row.cols-sm-11>*{max-width:91.66667%;flex-basis:91.66667%}.col-sm-offset-10{margin-left:83.33333%}.col-sm-12,.row.cols-sm-12>*{max-width:100%;flex-basis:100%}.col-sm-offset-11{margin-left:91.66667%}.col-sm-normal{order:initial}.col-sm-first{order:-999}.col-sm-last{order:999}@media screen and (min-width: 768px){.col-md,[class^='col-md-'],[class^='col-md-offset-'],.row[class*='cols-md-']>*{box-sizing:border-box;flex:0 0 auto;padding:0 calc(var(--universal-padding) / 2)}.col-md,.row.cols-md>*{max-width:100%;flex-grow:1;flex-basis:0}.col-md-1,.row.cols-md-1>*{max-width:8.33333%;flex-basis:8.33333%}.col-md-offset-0{margin-left:0}.col-md-2,.row.cols-md-2>*{max-width:16.66667%;flex-basis:16.66667%}.col-md-offset-1{margin-left:8.33333%}.col-md-3,.row.cols-md-3>*{max-width:25%;flex-basis:25%}.col-md-offset-2{margin-left:16.66667%}.col-md-4,.row.cols-md-4>*{max-width:33.33333%;flex-basis:33.33333%}.col-md-offset-3{margin-left:25%}.col-md-5,.row.cols-md-5>*{max-width:41.66667%;flex-basis:41.66667%}.col-md-offset-4{margin-left:33.33333%}.col-md-6,.row.cols-md-6>*{max-width:50%;flex-basis:50%}.col-md-offset-5{margin-left:41.66667%}.col-md-7,.row.cols-md-7>*{max-width:58.33333%;flex-basis:58.33333%}.col-md-offset-6{margin-left:50%}.col-md-8,.row.cols-md-8>*{max-width:66.66667%;flex-basis:66.66667%}.col-md-offset-7{margin-left:58.33333%}.col-md-9,.row.cols-md-9>*{max-width:75%;flex-basis:75%}.col-md-offset-8{margin-left:66.66667%}.col-md-10,.row.cols-md-10>*{max-width:83.33333%;flex-basis:83.33333%}.col-md-offset-9{margin-left:75%}.col-md-11,.row.cols-md-11>*{max-width:91.66667%;flex-basis:91.66667%}.col-md-offset-10{margin-left:83.33333%}.col-md-12,.row.cols-md-12>*{max-width:100%;flex-basis:100%}.col-md-offset-11{margin-left:91.66667%}.col-md-normal{order:initial}.col-md-first{order:-999}.col-md-last{order:999}}@media screen and (min-width: 1280px){.col-lg,[class^='col-lg-'],[class^='col-lg-offset-'],.row[class*='cols-lg-']>*{box-sizing:border-box;flex:0 0 auto;padding:0 calc(var(--universal-padding) / 2)}.col-lg,.row.cols-lg>*{max-width:100%;flex-grow:1;flex-basis:0}.col-lg-1,.row.cols-lg-1>*{max-width:8.33333%;flex-basis:8.33333%}.col-lg-offset-0{margin-left:0}.col-lg-2,.row.cols-lg-2>*{max-width:16.66667%;flex-basis:16.66667%}.col-lg-offset-1{margin-left:8.33333%}.col-lg-3,.row.cols-lg-3>*{max-width:25%;flex-basis:25%}.col-lg-offset-2{margin-left:16.66667%}.col-lg-4,.row.cols-lg-4>*{max-width:33.33333%;flex-basis:33.33333%}.col-lg-offset-3{margin-left:25%}.col-lg-5,.row.cols-lg-5>*{max-width:41.66667%;flex-basis:41.66667%}.col-lg-offset-4{margin-left:33.33333%}.col-lg-6,.row.cols-lg-6>*{max-width:50%;flex-basis:50%}.col-lg-offset-5{margin-left:41.66667%}.col-lg-7,.row.cols-lg-7>*{max-width:58.33333%;flex-basis:58.33333%}.col-lg-offset-6{margin-left:50%}.col-lg-8,.row.cols-lg-8>*{max-width:66.66667%;flex-basis:66.66667%}.col-lg-offset-7{margin-left:58.33333%}.col-lg-9,.row.cols-lg-9>*{max-width:75%;flex-basis:75%}.col-lg-offset-8{margin-left:66.66667%}.col-lg-10,.row.cols-lg-10>*{max-width:83.33333%;flex-basis:83.33333%}.col-lg-offset-9{margin-left:75%}.col-lg-11,.row.cols-lg-11>*{max-width:91.66667%;flex-basis:91.66667%}.col-lg-offset-10{margin-left:83.33333%}.col-lg-12,.row.cols-lg-12>*{max-width:100%;flex-basis:100%}.col-lg-offset-11{margin-left:91.66667%}.col-lg-normal{order:initial}.col-lg-first{order:-999}.col-lg-last{order:999}}:root{--card-back-color:#f8f8f8;--card-fore-color:#111;--card-border-color:#ddd}.card{display:flex;flex-direction:column;justify-content:space-between;align-self:center;position:relative;width:100%;background:var(--card-back-color);color:var(--card-fore-color);border:.0625rem solid var(--card-border-color);border-radius:var(--universal-border-radius);margin:var(--universal-margin);overflow:hidden}@media screen and (min-width: 320px){.card{max-width:320px}}.card>.section{background:var(--card-back-color);color:var(--card-fore-color);box-sizing:border-box;margin:0;border:0;border-radius:0;border-bottom:.0625rem solid var(--card-border-color);padding:var(--universal-padding);width:100%}.card>.section.media{height:200px;padding:0;-o-object-fit:cover;object-fit:cover}.card>.section:last-child{border-bottom:0}@media screen and (min-width: 240px){.card.small{max-width:240px}}@media screen and (min-width: 480px){.card.large{max-width:480px}}.card.fluid{max-width:100%;width:auto}.card.warning{--card-back-color:#ffca28;--card-border-color:#e8b825}.card.error{--card-back-color:#b71c1c;--card-fore-color:#f8f8f8;--card-border-color:#a71a1a}.card>.section.dark{--card-back-color:#e0e0e0}.card>.section.double-padded{padding:calc(1.5 * var(--universal-padding))}:root{--form-back-color:#f0f0f0;--form-fore-color:#111;--form-border-color:#ddd;--input-back-color:#f8f8f8;--input-fore-color:#111;--input-border-color:#ddd;--input-focus-color:#0288d1;--input-invalid-color:#d32f2f;--button-back-color:#e2e2e2;--button-hover-back-color:#dcdcdc;--button-fore-color:#212121;--button-border-color:rgba(0,0,0,0);--button-hover-border-color:rgba(0,0,0,0);--button-group-border-color:rgba(124,124,124,0.54)}form{background:var(--form-back-color);color:var(--form-fore-color);border:.0625rem solid var(--form-border-color);border-radius:var(--universal-border-radius);margin:var(--universal-margin);padding:calc(2 * var(--universal-padding)) var(--universal-padding)}fieldset{border:.0625rem solid var(--form-border-color);border-radius:var(--universal-border-radius);margin:calc(var(--universal-margin) / 4);padding:var(--universal-padding)}legend{box-sizing:border-box;display:table;max-width:100%;white-space:normal;font-weight:700;padding:calc(var(--universal-padding) / 2)}label{padding:calc(var(--universal-padding) / 2) var(--universal-padding)}.input-group{display:inline-block}.input-group.fluid{display:flex;align-items:center;justify-content:center}.input-group.fluid>input{max-width:100%;flex-grow:1;flex-basis:0px}@media screen and (max-width: 767px){.input-group.fluid{align-items:stretch;flex-direction:column}}.input-group.vertical{display:flex;align-items:stretch;flex-direction:column}.input-group.vertical>input{max-width:100%;flex-grow:1;flex-basis:0px}[type="number"]::-webkit-inner-spin-button,[type="number"]::-webkit-outer-spin-button{height:auto}[type="search"]{-webkit-appearance:textfield;outline-offset:-2px}[type="search"]::-webkit-search-cancel-button,[type="search"]::-webkit-search-decoration{-webkit-appearance:none}input:not([type]),[type="text"],[type="email"],[type="number"],[type="search"],[type="password"],[type="url"],[type="tel"],[type="checkbox"],[type="radio"],textarea,select{box-sizing:border-box;background:var(--input-back-color);color:var(--input-fore-color);border:.0625rem solid var(--input-border-color);border-radius:var(--universal-border-radius);margin:calc(var(--universal-margin) / 2);padding:var(--universal-padding) calc(1.5 * var(--universal-padding))}input:not([type="button"]):not([type="submit"]):not([type="reset"]):hover,input:not([type="button"]):not([type="submit"]):not([type="reset"]):focus,textarea:hover,textarea:focus,select:hover,select:focus{border-color:var(--input-focus-color);box-shadow:none}input:not([type="button"]):not([type="submit"]):not([type="reset"]):invalid,input:not([type="button"]):not([type="submit"]):not([type="reset"]):focus:invalid,textarea:invalid,textarea:focus:invalid,select:invalid,select:focus:invalid{border-color:var(--input-invalid-color);box-shadow:none}input:not([type="button"]):not([type="submit"]):not([type="reset"])[readonly],textarea[readonly],select[readonly]{background:var(--secondary-back-color)}select{max-width:100%}option{overflow:hidden;text-overflow:ellipsis}[type="checkbox"],[type="radio"]{-webkit-appearance:none;-moz-appearance:none;appearance:none;position:relative;height:calc(1rem + var(--universal-padding) / 2);width:calc(1rem + var(--universal-padding) / 2);vertical-align:text-bottom;padding:0;flex-basis:calc(1rem + var(--universal-padding) / 2) !important;flex-grow:0 !important}[type="checkbox"]:checked:before,[type="radio"]:checked:before{position:absolute}[type="checkbox"]:checked:before{content:'\2713';font-family:sans-serif;font-size:calc(1rem + var(--universal-padding) / 2);top:calc(0rem - var(--universal-padding));left:calc(var(--universal-padding) / 4)}[type="radio"]{border-radius:100%}[type="radio"]:checked:before{border-radius:100%;content:'';top:calc(.0625rem + var(--universal-padding) / 2);left:calc(.0625rem + var(--universal-padding) / 2);background:var(--input-fore-color);width:0.5rem;height:0.5rem}:placeholder-shown{color:var(--input-fore-color)}::-ms-placeholder{color:var(--input-fore-color);opacity:0.54}button::-moz-focus-inner,[type="button"]::-moz-focus-inner,[type="reset"]::-moz-focus-inner,[type="submit"]::-moz-focus-inner{border-style:none;padding:0}button,html [type="button"],[type="reset"],[type="submit"]{-webkit-appearance:button}button{overflow:visible;text-transform:none}button,[type="button"],[type="submit"],[type="reset"],a.button,label.button,.button,a[role="button"],label[role="button"],[role="button"]{display:inline-block;background:var(--button-back-color);color:var(--button-fore-color);border:.0625rem solid var(--button-border-color);border-radius:var(--universal-border-radius);padding:var(--universal-padding) calc(1.5 * var(--universal-padding));margin:var(--universal-margin);text-decoration:none;cursor:pointer;transition:background 0.3s}button:hover,button:focus,[type="button"]:hover,[type="button"]:focus,[type="submit"]:hover,[type="submit"]:focus,[type="reset"]:hover,[type="reset"]:focus,a.button:hover,a.button:focus,label.button:hover,label.button:focus,.button:hover,.button:focus,a[role="button"]:hover,a[role="button"]:focus,label[role="button"]:hover,label[role="button"]:focus,[role="button"]:hover,[role="button"]:focus{background:var(--button-hover-back-color);border-color:var(--button-hover-border-color)}input:disabled,input[disabled],textarea:disabled,textarea[disabled],select:disabled,select[disabled],button:disabled,button[disabled],.button:disabled,.button[disabled],[role="button"]:disabled,[role="button"][disabled]{cursor:not-allowed;opacity:.75}.button-group{display:flex;border:.0625rem solid var(--button-group-border-color);border-radius:var(--universal-border-radius);margin:var(--universal-margin)}.button-group>button,.button-group [type="button"],.button-group>[type="submit"],.button-group>[type="reset"],.button-group>.button,.button-group>[role="button"]{margin:0;max-width:100%;flex:1 1 auto;text-align:center;border:0;border-radius:0;box-shadow:none}.button-group>:not(:first-child){border-left:.0625rem solid var(--button-group-border-color)}@media screen and (max-width: 767px){.button-group{flex-direction:column}.button-group>:not(:first-child){border:0;border-top:.0625rem solid var(--button-group-border-color)}}button.primary,[type="button"].primary,[type="submit"].primary,[type="reset"].primary,.button.primary,[role="button"].primary{--button-back-color:#1976d2;--button-fore-color:#f8f8f8}button.primary:hover,button.primary:focus,[type="button"].primary:hover,[type="button"].primary:focus,[type="submit"].primary:hover,[type="submit"].primary:focus,[type="reset"].primary:hover,[type="reset"].primary:focus,.button.primary:hover,.button.primary:focus,[role="button"].primary:hover,[role="button"].primary:focus{--button-hover-back-color:#1565c0}button.secondary,[type="button"].secondary,[type="submit"].secondary,[type="reset"].secondary,.button.secondary,[role="button"].secondary{--button-back-color:#d32f2f;--button-fore-color:#f8f8f8}button.secondary:hover,button.secondary:focus,[type="button"].secondary:hover,[type="button"].secondary:focus,[type="submit"].secondary:hover,[type="submit"].secondary:focus,[type="reset"].secondary:hover,[type="reset"].secondary:focus,.button.secondary:hover,.button.secondary:focus,[role="button"].secondary:hover,[role="button"].secondary:focus{--button-hover-back-color:#c62828}button.tertiary,[type="button"].tertiary,[type="submit"].tertiary,[type="reset"].tertiary,.button.tertiary,[role="button"].tertiary{--button-back-color:#308732;--button-fore-color:#f8f8f8}button.tertiary:hover,button.tertiary:focus,[type="button"].tertiary:hover,[type="button"].tertiary:focus,[type="submit"].tertiary:hover,[type="submit"].tertiary:focus,[type="reset"].tertiary:hover,[type="reset"].tertiary:focus,.button.tertiary:hover,.button.tertiary:focus,[role="button"].tertiary:hover,[role="button"].tertiary:focus{--button-hover-back-color:#277529}button.inverse,[type="button"].inverse,[type="submit"].inverse,[type="reset"].inverse,.button.inverse,[role="button"].inverse{--button-back-color:#212121;--button-fore-color:#f8f8f8}button.inverse:hover,button.inverse:focus,[type="button"].inverse:hover,[type="button"].inverse:focus,[type="submit"].inverse:hover,[type="submit"].inverse:focus,[type="reset"].inverse:hover,[type="reset"].inverse:focus,.button.inverse:hover,.button.inverse:focus,[role="button"].inverse:hover,[role="button"].inverse:focus{--button-hover-back-color:#111}button.small,[type="button"].small,[type="submit"].small,[type="reset"].small,.button.small,[role="button"].small{padding:calc(0.5 * var(--universal-padding)) calc(0.75 * var(--universal-padding));margin:var(--universal-margin)}button.large,[type="button"].large,[type="submit"].large,[type="reset"].large,.button.large,[role="button"].large{padding:calc(1.5 * var(--universal-padding)) calc(2 * var(--universal-padding));margin:var(--universal-margin)}:root{--header-back-color:#f8f8f8;--header-hover-back-color:#f0f0f0;--header-fore-color:#444;--header-border-color:#ddd;--nav-back-color:#f8f8f8;--nav-hover-back-color:#f0f0f0;--nav-fore-color:#444;--nav-border-color:#ddd;--nav-link-color:#0277bd;--footer-fore-color:#444;--footer-back-color:#f8f8f8;--footer-border-color:#ddd;--footer-link-color:#0277bd;--drawer-back-color:#f8f8f8;--drawer-hover-back-color:#f0f0f0;--drawer-border-color:#ddd;--drawer-close-color:#444}header{height:3.1875rem;background:var(--header-back-color);color:var(--header-fore-color);border-bottom:.0625rem solid var(--header-border-color);padding:calc(var(--universal-padding) / 4) 0;white-space:nowrap;overflow-x:auto;overflow-y:hidden}header.row{box-sizing:content-box}header .logo{color:var(--header-fore-color);font-size:1.75rem;padding:var(--universal-padding) calc(2 * var(--universal-padding));text-decoration:none}header button,header [type="button"],header .button,header [role="button"]{box-sizing:border-box;position:relative;top:calc(0rem - var(--universal-padding) / 4);height:calc(3.1875rem + var(--universal-padding) / 2);background:var(--header-back-color);line-height:calc(3.1875rem - var(--universal-padding) * 1.5);text-align:center;color:var(--header-fore-color);border:0;border-radius:0;margin:0;text-transform:uppercase}header button:hover,header button:focus,header [type="button"]:hover,header [type="button"]:focus,header .button:hover,header .button:focus,header [role="button"]:hover,header [role="button"]:focus{background:var(--header-hover-back-color)}nav{background:var(--nav-back-color);color:var(--nav-fore-color);border:.0625rem solid var(--nav-border-color);border-radius:var(--universal-border-radius);margin:var(--universal-margin)}nav *{padding:var(--universal-padding) calc(1.5 * var(--universal-padding))}nav a,nav a:visited{display:block;color:var(--nav-link-color);border-radius:var(--universal-border-radius);transition:background 0.3s}nav a:hover,nav a:focus,nav a:visited:hover,nav a:visited:focus{text-decoration:none;background:var(--nav-hover-back-color)}nav .sublink-1{position:relative;margin-left:calc(2 * var(--universal-padding))}nav .sublink-1:before{position:absolute;left:calc(var(--universal-padding) - 1 * var(--universal-padding));top:-.0625rem;content:'';height:100%;border:.0625rem solid var(--nav-border-color);border-left:0}nav .sublink-2{position:relative;margin-left:calc(4 * var(--universal-padding))}nav .sublink-2:before{position:absolute;left:calc(var(--universal-padding) - 3 * var(--universal-padding));top:-.0625rem;content:'';height:100%;border:.0625rem solid var(--nav-border-color);border-left:0}footer{background:var(--footer-back-color);color:var(--footer-fore-color);border-top:.0625rem solid var(--footer-border-color);padding:calc(2 * var(--universal-padding)) var(--universal-padding);font-size:.875rem}footer a,footer a:visited{color:var(--footer-link-color)}header.sticky{position:-webkit-sticky;position:sticky;z-index:1101;top:0}footer.sticky{position:-webkit-sticky;position:sticky;z-index:1101;bottom:0}.drawer-toggle:before{display:inline-block;position:relative;vertical-align:bottom;content:'\00a0\2261\00a0';font-family:sans-serif;font-size:1.5em}@media screen and (min-width: 768px){.drawer-toggle:not(.persistent){display:none}}[type="checkbox"].drawer{height:1px;width:1px;margin:-1px;overflow:hidden;position:absolute;clip:rect(0 0 0 0);-webkit-clip-path:inset(100%);clip-path:inset(100%)}[type="checkbox"].drawer+*{display:block;box-sizing:border-box;position:fixed;top:0;width:320px;height:100vh;overflow-y:auto;background:var(--drawer-back-color);border:.0625rem solid var(--drawer-border-color);border-radius:0;margin:0;z-index:1110;right:-320px;transition:right 0.3s}[type="checkbox"].drawer+* .drawer-close{position:absolute;top:var(--universal-margin);right:var(--universal-margin);z-index:1111;width:2rem;height:2rem;border-radius:var(--universal-border-radius);padding:var(--universal-padding);margin:0;cursor:pointer;transition:background 0.3s}[type="checkbox"].drawer+* .drawer-close:before{display:block;content:'\00D7';color:var(--drawer-close-color);position:relative;font-family:sans-serif;font-size:2rem;line-height:1;text-align:center}[type="checkbox"].drawer+* .drawer-close:hover,[type="checkbox"].drawer+* .drawer-close:focus{background:var(--drawer-hover-back-color)}@media screen and (max-width: 320px){[type="checkbox"].drawer+*{width:100%}}[type="checkbox"].drawer:checked+*{right:0}@media screen and (min-width: 768px){[type="checkbox"].drawer:not(.persistent)+*{position:static;height:100%;z-index:1100}[type="checkbox"].drawer:not(.persistent)+* .drawer-close{display:none}}:root{--table-border-color:#aaa;--table-border-separator-color:#666;--table-head-back-color:#e6e6e6;--table-head-fore-color:#111;--table-body-back-color:#f8f8f8;--table-body-fore-color:#111;--table-body-alt-back-color:#eee}table{border-collapse:separate;border-spacing:0;margin:0;display:flex;flex:0 1 auto;flex-flow:row wrap;padding:var(--universal-padding);padding-top:0}table caption{font-size:1.5rem;margin:calc(2 * var(--universal-margin)) 0;max-width:100%;flex:0 0 100%}table thead,table tbody{display:flex;flex-flow:row wrap;border:.0625rem solid var(--table-border-color)}table thead{z-index:999;border-radius:var(--universal-border-radius) var(--universal-border-radius) 0 0;border-bottom:.0625rem solid var(--table-border-separator-color)}table tbody{border-top:0;margin-top:calc(0 - var(--universal-margin));border-radius:0 0 var(--universal-border-radius) var(--universal-border-radius)}table tr{display:flex;padding:0}table th,table td{padding:calc(2 * var(--universal-padding))}table th{text-align:left;background:var(--table-head-back-color);color:var(--table-head-fore-color)}table td{background:var(--table-body-back-color);color:var(--table-body-fore-color);border-top:.0625rem solid var(--table-border-color)}table:not(.horizontal){overflow:auto;max-height:400px}table:not(.horizontal) thead,table:not(.horizontal) tbody{max-width:100%;flex:0 0 100%}table:not(.horizontal) tr{flex-flow:row wrap;flex:0 0 100%}table:not(.horizontal) th,table:not(.horizontal) td{flex:1 0 0%;overflow:hidden;text-overflow:ellipsis}table:not(.horizontal) thead{position:sticky;top:0}table:not(.horizontal) tbody tr:first-child td{border-top:0}table.horizontal{border:0}table.horizontal thead,table.horizontal tbody{border:0;flex:.2 0 0;flex-flow:row nowrap}table.horizontal tbody{overflow:auto;justify-content:space-between;flex:.8 0 0;margin-left:0;padding-bottom:calc(var(--universal-padding) / 4)}table.horizontal tr{flex-direction:column;flex:1 0 auto}table.horizontal th,table.horizontal td{width:auto;border:0;border-bottom:.0625rem solid var(--table-border-color)}table.horizontal th:not(:first-child),table.horizontal td:not(:first-child){border-top:0}table.horizontal th{text-align:right;border-left:.0625rem solid var(--table-border-color);border-right:.0625rem solid var(--table-border-separator-color)}table.horizontal thead tr:first-child{padding-left:0}table.horizontal th:first-child,table.horizontal td:first-child{border-top:.0625rem solid var(--table-border-color)}table.horizontal tbody tr:last-child td{border-right:.0625rem solid var(--table-border-color)}table.horizontal tbody tr:last-child td:first-child{border-top-right-radius:0.25rem}table.horizontal tbody tr:last-child td:last-child{border-bottom-right-radius:0.25rem}table.horizontal thead tr:first-child th:first-child{border-top-left-radius:0.25rem}table.horizontal thead tr:first-child th:last-child{border-bottom-left-radius:0.25rem}@media screen and (max-width: 767px){table,table.horizontal{border-collapse:collapse;border:0;width:100%;display:table}table thead,table th,table.horizontal thead,table.horizontal th{border:0;height:1px;width:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;clip:rect(0 0 0 0);-webkit-clip-path:inset(100%);clip-path:inset(100%)}table tbody,table.horizontal tbody{border:0;display:table-row-group}table tr,table.horizontal tr{display:block;border:.0625rem solid var(--table-border-color);border-radius:var(--universal-border-radius);background:#fafafa;padding:var(--universal-padding);margin:var(--universal-margin);margin-bottom:calc(2 * var(--universal-margin))}table th,table td,table.horizontal th,table.horizontal td{width:auto}table td,table.horizontal td{display:block;border:0;text-align:right}table td:before,table.horizontal td:before{content:attr(data-label);float:left;font-weight:600}table th:first-child,table td:first-child,table.horizontal th:first-child,table.horizontal td:first-child{border-top:0}table tbody tr:last-child td,table.horizontal tbody tr:last-child td{border-right:0}}:root{--table-body-alt-back-color:#eee}table.striped tr:nth-of-type(2n)>td{background:var(--table-body-alt-back-color)}@media screen and (max-width: 768px){table.striped tr:nth-of-type(2n){background:var(--table-body-alt-back-color)}}:root{--table-body-hover-back-color:#90caf9}table.hoverable tr:hover,table.hoverable tr:hover>td,table.hoverable tr:focus,table.hoverable tr:focus>td{background:var(--table-body-hover-back-color)}@media screen and (max-width: 768px){table.hoverable tr:hover,table.hoverable tr:hover>td,table.hoverable tr:focus,table.hoverable tr:focus>td{background:var(--table-body-hover-back-color)}}:root{--mark-back-color:#0277bd;--mark-fore-color:#fafafa}mark{background:var(--mark-back-color);color:var(--mark-fore-color);font-size:.95em;line-height:1em;border-radius:var(--universal-border-radius);padding:calc(var(--universal-padding) / 4) calc(var(--universal-padding) / 2)}mark.inline-block{display:inline-block;font-size:1em;line-height:1.5;padding:calc(var(--universal-padding) / 2) var(--universal-padding)}:root{--toast-back-color:#424242;--toast-fore-color:#fafafa}.toast{position:fixed;bottom:calc(var(--universal-margin) * 3);left:50%;transform:translate(-50%, -50%);z-index:1111;color:var(--toast-fore-color);background:var(--toast-back-color);border-radius:calc(var(--universal-border-radius) * 16);padding:var(--universal-padding) calc(var(--universal-padding) * 3)}:root{--tooltip-back-color:#212121;--tooltip-fore-color:#fafafa}.tooltip{position:relative;display:inline-block}.tooltip:before,.tooltip:after{position:absolute;opacity:0;clip:rect(0 0 0 0);-webkit-clip-path:inset(100%);clip-path:inset(100%);transition:all 0.3s;z-index:1010;left:50%}.tooltip:not(.bottom):before,.tooltip:not(.bottom):after{bottom:75%}.tooltip.bottom:before,.tooltip.bottom:after{top:75%}.tooltip:hover:before,.tooltip:hover:after,.tooltip:focus:before,.tooltip:focus:after{opacity:1;clip:auto;-webkit-clip-path:inset(0%);clip-path:inset(0%)}.tooltip:before{content:'';background:transparent;border:var(--universal-margin) solid transparent;left:calc(50% - var(--universal-margin))}.tooltip:not(.bottom):before{border-top-color:#212121}.tooltip.bottom:before{border-bottom-color:#212121}.tooltip:after{content:attr(aria-label);color:var(--tooltip-fore-color);background:var(--tooltip-back-color);border-radius:var(--universal-border-radius);padding:var(--universal-padding);white-space:nowrap;transform:translateX(-50%)}.tooltip:not(.bottom):after{margin-bottom:calc(2 * var(--universal-margin))}.tooltip.bottom:after{margin-top:calc(2 * var(--universal-margin))}:root{--modal-overlay-color:rgba(0,0,0,0.45);--modal-close-color:#444;--modal-close-hover-color:#f0f0f0}[type="checkbox"].modal{height:1px;width:1px;margin:-1px;overflow:hidden;position:absolute;clip:rect(0 0 0 0);-webkit-clip-path:inset(100%);clip-path:inset(100%)}[type="checkbox"].modal+div{position:fixed;top:0;left:0;display:none;width:100vw;height:100vh;background:var(--modal-overlay-color)}[type="checkbox"].modal+div .card{margin:0 auto;max-height:50vh;overflow:auto}[type="checkbox"].modal+div .card .modal-close{position:absolute;top:0;right:0;width:1.75rem;height:1.75rem;border-radius:var(--universal-border-radius);padding:var(--universal-padding);margin:0;cursor:pointer;transition:background 0.3s}[type="checkbox"].modal+div .card .modal-close:before{display:block;content:'\00D7';color:var(--modal-close-color);position:relative;font-family:sans-serif;font-size:1.75rem;line-height:1;text-align:center}[type="checkbox"].modal+div .card .modal-close:hover,[type="checkbox"].modal+div .card .modal-close:focus{background:var(--modal-close-hover-color)}[type="checkbox"].modal:checked+div{display:flex;flex:0 1 auto;z-index:1200}[type="checkbox"].modal:checked+div .card .modal-close{z-index:1211}:root{--collapse-label-back-color:#e8e8e8;--collapse-label-fore-color:#212121;--collapse-label-hover-back-color:#f0f0f0;--collapse-selected-label-back-color:#ececec;--collapse-border-color:#ddd;--collapse-content-back-color:#fafafa;--collapse-selected-label-border-color:#0277bd}.collapse{width:calc(100% - 2 * var(--universal-margin));opacity:1;display:flex;flex-direction:column;margin:var(--universal-margin);border-radius:var(--universal-border-radius)}.collapse>[type="radio"],.collapse>[type="checkbox"]{height:1px;width:1px;margin:-1px;overflow:hidden;position:absolute;clip:rect(0 0 0 0);-webkit-clip-path:inset(100%);clip-path:inset(100%)}.collapse>label{flex-grow:1;display:inline-block;height:1.5rem;cursor:pointer;transition:background 0.3s;color:var(--collapse-label-fore-color);background:var(--collapse-label-back-color);border:.0625rem solid var(--collapse-border-color);padding:calc(1.5 * var(--universal-padding))}.collapse>label:hover,.collapse>label:focus{background:var(--collapse-label-hover-back-color)}.collapse>label+div{flex-basis:auto;height:1px;width:1px;margin:-1px;overflow:hidden;position:absolute;clip:rect(0 0 0 0);-webkit-clip-path:inset(100%);clip-path:inset(100%);transition:max-height 0.3s;max-height:1px}.collapse>:checked+label{background:var(--collapse-selected-label-back-color);border-bottom-color:var(--collapse-selected-label-border-color)}.collapse>:checked+label+div{box-sizing:border-box;position:relative;width:100%;height:auto;overflow:auto;margin:0;background:var(--collapse-content-back-color);border:.0625rem solid var(--collapse-border-color);border-top:0;padding:var(--universal-padding);clip:auto;-webkit-clip-path:inset(0%);clip-path:inset(0%);max-height:400px}.collapse>label:not(:first-of-type){border-top:0}.collapse>label:first-of-type{border-radius:var(--universal-border-radius) var(--universal-border-radius) 0 0}.collapse>label:last-of-type:not(:first-of-type){border-radius:0 0 var(--universal-border-radius) var(--universal-border-radius)}.collapse>label:last-of-type:first-of-type{border-radius:var(--universal-border-radius)}.collapse>:checked:last-of-type:not(:first-of-type)+label{border-radius:0}.collapse>:checked:last-of-type+label+div{border-radius:0 0 var(--universal-border-radius) var(--universal-border-radius)}mark.secondary{--mark-back-color:#d32f2f}mark.tertiary{--mark-back-color:#308732}mark.tag{padding:calc(var(--universal-padding)/2) var(--universal-padding);border-radius:1em}:root{--progress-back-color:#ddd;--progress-fore-color:#555}progress{display:block;vertical-align:baseline;-webkit-appearance:none;-moz-appearance:none;appearance:none;height:.75rem;width:calc(100% - 2 * var(--universal-margin));margin:var(--universal-margin);border:0;border-radius:calc(2 * var(--universal-border-radius));background:var(--progress-back-color);color:var(--progress-fore-color)}progress::-webkit-progress-value{background:var(--progress-fore-color);border-top-left-radius:calc(2 * var(--universal-border-radius));border-bottom-left-radius:calc(2 * var(--universal-border-radius))}progress::-webkit-progress-bar{background:var(--progress-back-color)}progress::-moz-progress-bar{background:var(--progress-fore-color);border-top-left-radius:calc(2 * var(--universal-border-radius));border-bottom-left-radius:calc(2 * var(--universal-border-radius))}progress[value="1000"]::-webkit-progress-value{border-radius:calc(2 * var(--universal-border-radius))}progress[value="1000"]::-moz-progress-bar{border-radius:calc(2 * var(--universal-border-radius))}progress.inline{display:inline-block;vertical-align:middle;width:60%}:root{--spinner-back-color:#ddd;--spinner-fore-color:#555}@keyframes spinner-donut-anim{0%{transform:rotate(0deg)}100%{transform:rotate(360deg)}}.spinner{display:inline-block;margin:var(--universal-margin);border:.25rem solid var(--spinner-back-color);border-left:.25rem solid var(--spinner-fore-color);border-radius:50%;width:1.25rem;height:1.25rem;animation:spinner-donut-anim 1.2s linear infinite}progress.primary{--progress-fore-color:#1976d2}progress.secondary{--progress-fore-color:#d32f2f}progress.tertiary{--progress-fore-color:#308732}.spinner.primary{--spinner-fore-color:#1976d2}.spinner.secondary{--spinner-fore-color:#d32f2f}.spinner.tertiary{--spinner-fore-color:#308732}span[class^='icon-']{display:inline-block;height:1em;width:1em;vertical-align:-0.125em;background-size:contain;margin:0 calc(var(--universal-margin) / 4)}span[class^='icon-'].secondary{-webkit-filter:invert(25%);filter:invert(25%)}span[class^='icon-'].inverse{-webkit-filter:invert(100%);filter:invert(100%)}span.icon-alert{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23111' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12' y2='16'%3E%3C/line%3E%3C/svg%3E")}span.icon-bookmark{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23111' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z'%3E%3C/path%3E%3C/svg%3E")}span.icon-calendar{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23111' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='18' height='18' rx='2' ry='2'%3E%3C/rect%3E%3Cline x1='16' y1='2' x2='16' y2='6'%3E%3C/line%3E%3Cline x1='8' y1='2' x2='8' y2='6'%3E%3C/line%3E%3Cline x1='3' y1='10' x2='21' y2='10'%3E%3C/line%3E%3C/svg%3E")}span.icon-credit{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23111' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='1' y='4' width='22' height='16' rx='2' ry='2'%3E%3C/rect%3E%3Cline x1='1' y1='10' x2='23' y2='10'%3E%3C/line%3E%3C/svg%3E")}span.icon-edit{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23111' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 14.66V20a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h5.34'%3E%3C/path%3E%3Cpolygon points='18 2 22 6 12 16 8 16 8 12 18 2'%3E%3C/polygon%3E%3C/svg%3E")}span.icon-link{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23111' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6'%3E%3C/path%3E%3Cpolyline points='15 3 21 3 21 9'%3E%3C/polyline%3E%3Cline x1='10' y1='14' x2='21' y2='3'%3E%3C/line%3E%3C/svg%3E")}span.icon-help{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23111' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3'%3E%3C/path%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='17' x2='12' y2='17'%3E%3C/line%3E%3C/svg%3E")}span.icon-home{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23111' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z'%3E%3C/path%3E%3Cpolyline points='9 22 9 12 15 12 15 22'%3E%3C/polyline%3E%3C/svg%3E")}span.icon-info{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23111' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='16' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='8' x2='12' y2='8'%3E%3C/line%3E%3C/svg%3E")}span.icon-lock{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23111' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='11' width='18' height='11' rx='2' ry='2'%3E%3C/rect%3E%3Cpath d='M7 11V7a5 5 0 0 1 10 0v4'%3E%3C/path%3E%3C/svg%3E")}span.icon-mail{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23111' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z'%3E%3C/path%3E%3Cpolyline points='22,6 12,13 2,6'%3E%3C/polyline%3E%3C/svg%3E")}span.icon-location{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23111' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z'%3E%3C/path%3E%3Ccircle cx='12' cy='10' r='3'%3E%3C/circle%3E%3C/svg%3E")}span.icon-phone{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23111' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z'%3E%3C/path%3E%3C/svg%3E")}span.icon-rss{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23111' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 11a9 9 0 0 1 9 9'%3E%3C/path%3E%3Cpath d='M4 4a16 16 0 0 1 16 16'%3E%3C/path%3E%3Ccircle cx='5' cy='19' r='1'%3E%3C/circle%3E%3C/svg%3E")}span.icon-search{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23111' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='8'%3E%3C/circle%3E%3Cline x1='21' y1='21' x2='16.65' y2='16.65'%3E%3C/line%3E%3C/svg%3E")}span.icon-settings{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23111' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='3'%3E%3C/circle%3E%3Cpath d='M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z'%3E%3C/path%3E%3C/svg%3E")}span.icon-share{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23111' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='18' cy='5' r='3'%3E%3C/circle%3E%3Ccircle cx='6' cy='12' r='3'%3E%3C/circle%3E%3Ccircle cx='18' cy='19' r='3'%3E%3C/circle%3E%3Cline x1='8.59' y1='13.51' x2='15.42' y2='17.49'%3E%3C/line%3E%3Cline x1='15.41' y1='6.51' x2='8.59' y2='10.49'%3E%3C/line%3E%3C/svg%3E")}span.icon-cart{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23111' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='9' cy='21' r='1'%3E%3C/circle%3E%3Ccircle cx='20' cy='21' r='1'%3E%3C/circle%3E%3Cpath d='M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6'%3E%3C/path%3E%3C/svg%3E")}span.icon-upload{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23111' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4'%3E%3C/path%3E%3Cpolyline points='17 8 12 3 7 8'%3E%3C/polyline%3E%3Cline x1='12' y1='3' x2='12' y2='15'%3E%3C/line%3E%3C/svg%3E")}span.icon-user{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23111' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2'%3E%3C/path%3E%3Ccircle cx='12' cy='7' r='4'%3E%3C/circle%3E%3C/svg%3E")}:root{--generic-border-color:rgba(0,0,0,0.3);--generic-box-shadow:0 .25rem .25rem 0 rgba(0,0,0,0.125),0 .125rem .125rem -.125rem rgba(0,0,0,0.25)}.hidden{display:none !important}.visually-hidden{position:absolute !important;width:1px !important;height:1px !important;margin:-1px !important;border:0 !important;padding:0 !important;clip:rect(0 0 0 0) !important;-webkit-clip-path:inset(100%) !important;clip-path:inset(100%) !important;overflow:hidden !important}.bordered{border:.0625rem solid var(--generic-border-color) !important}.rounded{border-radius:var(--universal-border-radius) !important}.circular{border-radius:50% !important}.shadowed{box-shadow:var(--generic-box-shadow) !important}.responsive-margin{margin:calc(var(--universal-margin) / 4) !important}@media screen and (min-width: 768px){.responsive-margin{margin:calc(var(--universal-margin) / 2) !important}}@media screen and (min-width: 1280px){.responsive-margin{margin:var(--universal-margin) !important}}.responsive-padding{padding:calc(var(--universal-padding) / 4) !important}@media screen and (min-width: 768px){.responsive-padding{padding:calc(var(--universal-padding) / 2) !important}}@media screen and (min-width: 1280px){.responsive-padding{padding:var(--universal-padding) !important}}@media screen and (max-width: 767px){.hidden-sm{display:none !important}}@media screen and (min-width: 768px) and (max-width: 1279px){.hidden-md{display:none !important}}@media screen and (min-width: 1280px){.hidden-lg{display:none !important}}@media screen and (max-width: 767px){.visually-hidden-sm{position:absolute !important;width:1px !important;height:1px !important;margin:-1px !important;border:0 !important;padding:0 !important;clip:rect(0 0 0 0) !important;-webkit-clip-path:inset(100%) !important;clip-path:inset(100%) !important;overflow:hidden !important}}@media screen and (min-width: 768px) and (max-width: 1279px){.visually-hidden-md{position:absolute !important;width:1px !important;height:1px !important;margin:-1px !important;border:0 !important;padding:0 !important;clip:rect(0 0 0 0) !important;-webkit-clip-path:inset(100%) !important;clip-path:inset(100%) !important;overflow:hidden !important}}@media screen and (min-width: 1280px){.visually-hidden-lg{position:absolute !important;width:1px !important;height:1px !important;margin:-1px !important;border:0 !important;padding:0 !important;clip:rect(0 0 0 0) !important;-webkit-clip-path:inset(100%) !important;clip-path:inset(100%) !important;overflow:hidden !important}} 2 | --------------------------------------------------------------------------------