├── 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 |
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 |
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 |
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 |
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 |
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 |
5 |
6 |
 }})
7 |
8 |
9 |
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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------
/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 | Users
8 |
9 |
10 | | ID |
11 | Fullname |
12 | Email |
13 | Role |
14 |
15 |
16 |
17 | {% for user in users %}
18 |
19 | | {{ user.id }} |
20 | {{ user.fullname or '-' }} |
21 | {{ user.email }} |
22 | {{ 'Admin' if user.role else 'User' }} |
23 |
24 | {% endfor %}
25 |
26 |
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 |
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 | Posts
8 |
9 |
10 | | ID |
11 | Title |
12 | Actions |
13 |
14 |
15 |
16 | {% for post in posts %}
17 |
18 | | {{ post.id }} |
19 |
20 | {{ post.title }}
21 | |
22 |
23 | Delete
24 | Modify
25 | |
26 |
27 | {% endfor %}
28 |
29 |
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 | Categories
8 |
9 |
10 | | ID |
11 | Name |
12 | Actions |
13 |
14 |
15 |
16 | {% for category in categories %}
17 |
18 | | {{ category.id }} |
19 |
20 | {{ category.name }}
21 | |
22 |
23 | Delete
24 | Modify
25 | |
26 |
27 | {% endfor %}
28 |
29 |
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 |
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 |
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 |
--------------------------------------------------------------------------------