├── tests ├── __init__.py ├── test_group.py ├── test_auth.py ├── base.py ├── test_me.py └── test_post.py ├── flemi ├── api │ ├── __init__.py │ ├── auth │ │ ├── __init__.py │ │ ├── schemas.py │ │ └── views.py │ ├── group │ │ ├── __init__.py │ │ ├── schemas.py │ │ └── views.py │ ├── me │ │ ├── __init__.py │ │ ├── schemas.py │ │ └── views.py │ ├── post │ │ ├── __init__.py │ │ ├── schemas.py │ │ └── views.py │ ├── user │ │ ├── __init__.py │ │ ├── schemas.py │ │ └── views.py │ └── decorators.py ├── emails.py ├── extensions.py ├── templates │ ├── confirm.txt │ └── confirm.html ├── commands │ ├── user.py │ ├── cli.py │ ├── __init__.py │ └── auth.py ├── __init__.py ├── utils.py ├── settings.py ├── fakes.py ├── shop_items.py └── models.py ├── migrations ├── README ├── script.py.mako ├── versions │ ├── 00fdded8fed4_enable_user_address.py │ └── dc9d5c6bed53_password_update.py ├── alembic.ini └── env.py ├── .flake8 ├── wsgi.py ├── .gitignore ├── .pre-commit-config.yaml ├── .github └── workflows │ └── test.yml ├── pyproject.toml └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /flemi/api/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /flemi/api/auth/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /flemi/api/group/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /flemi/api/me/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /flemi/api/post/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /flemi/api/user/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /migrations/README: -------------------------------------------------------------------------------- 1 | Single-database configuration for Flask. 2 | -------------------------------------------------------------------------------- /flemi/emails.py: -------------------------------------------------------------------------------- 1 | def send_email(recipients: list, subject=None, template=None, **kwargs): 2 | pass # TODO 3 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = .git,__pypackages__,docs/source/conf.py,build,dist,src/djask/project_template,migrations 3 | max-line-length = 99 4 | -------------------------------------------------------------------------------- /flemi/extensions.py: -------------------------------------------------------------------------------- 1 | from apiflask import HTTPTokenAuth 2 | from flask_marshmallow import Marshmallow 3 | from flask_migrate import Migrate 4 | from flask_sqlalchemy import SQLAlchemy 5 | 6 | 7 | db = SQLAlchemy() 8 | migrate = Migrate() 9 | auth = HTTPTokenAuth() 10 | ma = Marshmallow() 11 | -------------------------------------------------------------------------------- /wsgi.py: -------------------------------------------------------------------------------- 1 | import os 2 | from dotenv import load_dotenv 3 | 4 | dotenv_path = os.path.join(os.path.dirname(__file__), ".env") 5 | if os.path.exists(dotenv_path): 6 | load_dotenv(dotenv_path) 7 | 8 | from flemi import create_app # noqa 9 | 10 | app = create_app(os.getenv("FLASK_CONFIG", "production")) 11 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /flemi/templates/confirm.txt: -------------------------------------------------------------------------------- 1 | Hello {{ username }}, 2 | {% if mode == 1 %} 3 | Welcome to Flemi! We are glad to know that you joined us. To continue your 4 | Flemi journey, your email must be confirmed. 5 | {% elif mode == 2 %} 6 | It seems that you need a resend of confirmation email. All right - that's 7 | it. 8 | {% elif mode == 3 %} 9 | Someone attempted to perform sensitive operations on your account. If it's 10 | not your operation, please ignore this email and consider changing a 11 | password in case your account is stolen. 12 | {% endif %} 13 | Your token: {{ token }} 14 | Please copy this token to your browser to continue. 15 | -------------------------------------------------------------------------------- /flemi/api/user/schemas.py: -------------------------------------------------------------------------------- 1 | from apiflask import Schema 2 | from apiflask.fields import Boolean 3 | from apiflask.fields import DateTime 4 | from apiflask.fields import Integer 5 | from apiflask.fields import String 6 | from flemi.extensions import ma 7 | 8 | 9 | class PublicUserOutSchema(Schema): 10 | id = Integer() 11 | username = String() 12 | name = String() 13 | location = String() 14 | about_me = String() 15 | confirmed = Boolean() 16 | blocked = Boolean() 17 | member_since = DateTime() 18 | last_seen = DateTime() 19 | self = ma.URLFor("user.user", values=dict(user_id="")) # type: ignore 20 | -------------------------------------------------------------------------------- /flemi/api/group/schemas.py: -------------------------------------------------------------------------------- 1 | from apiflask import Schema 2 | from apiflask.fields import Boolean 3 | from apiflask.fields import Integer 4 | from apiflask.fields import List 5 | from apiflask.fields import Nested 6 | from apiflask.fields import String 7 | 8 | from ..user.schemas import PublicUserOutSchema 9 | 10 | 11 | class GroupInSchema(Schema): 12 | name = String(required=True) 13 | members = List(Integer()) 14 | private = Boolean() 15 | 16 | 17 | class GroupOutSchema(Schema): 18 | name = String() 19 | members = List(Nested(PublicUserOutSchema)) 20 | manager = Nested(PublicUserOutSchema) 21 | private = Boolean() 22 | -------------------------------------------------------------------------------- /flemi/commands/user.py: -------------------------------------------------------------------------------- 1 | import click 2 | from flask import Flask 3 | 4 | 5 | def register_user_group(app: Flask, db): 6 | @app.cli.group() 7 | def user(): 8 | """ 9 | User commands 10 | """ 11 | pass 12 | 13 | @user.command() 14 | @click.option("--username", default=None, help="query from username") 15 | @click.option("--email", default=None, help="query from email") 16 | @click.option("--name", default=None, help="query from name") 17 | @click.option("--location", default=None, help="query from location") 18 | def query(): 19 | """ 20 | user query to id (returns list) 21 | """ 22 | -------------------------------------------------------------------------------- /flemi/api/auth/schemas.py: -------------------------------------------------------------------------------- 1 | from apiflask import Schema 2 | from apiflask.fields import Email 3 | from apiflask.fields import String 4 | from marshmallow import validate 5 | 6 | 7 | class LoginSchema(Schema): 8 | username = String(required=True) 9 | password = String(required=True) 10 | 11 | 12 | class RegisterSchema(Schema): 13 | username = String( 14 | required=True, validate=validate.Regexp(r"^[A-Za-z]([A-Za-z0-9_\-.]){2,15}$", 0) 15 | ) 16 | password = String(required=True) 17 | email = Email(required=True) 18 | about_me = String() 19 | name = String(required=True) 20 | 21 | 22 | # class EmailConfirmationSchema(Schema): 23 | # token = String(required=True) 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Installer logs 10 | pip-log.txt 11 | pip-delete-this-directory.txt 12 | 13 | # Unit test / coverage reports 14 | htmlcov/ 15 | .tox/ 16 | .coverage 17 | .coverage.* 18 | .cache 19 | nosetests.xml 20 | coverage.xml 21 | *.cover 22 | .hypothesis/ 23 | .pytest_cache/ 24 | 25 | # pyenv 26 | .python-version 27 | 28 | # Environments 29 | .env 30 | .venv 31 | env/ 32 | venv/ 33 | ENV/ 34 | env.bak/ 35 | venv.bak/ 36 | 37 | # mypy 38 | .mypy_cache/ 39 | 40 | # db 41 | *.sqlite3 42 | 43 | # pdm stuff 44 | .pdm.toml 45 | 46 | # vscode stuff 47 | .vscode/ 48 | 49 | # pyright stuff 50 | pyrightconfig.json 51 | -------------------------------------------------------------------------------- /flemi/templates/confirm.html: -------------------------------------------------------------------------------- 1 |
2 | Hello {{ username }},
3 | {% if mode == 1 %} 4 | Welcome to Flemi! We are glad to know that you joined us. To continue your 5 | Flemi journey, your email must be confirmed. 6 | {% elif mode == 2 %} 7 | It seems that you need a resend of confirmation email. All right - that's 8 | it. 9 | {% elif mode == 3 %} 10 | Someone attempted to perform sensitive operations on your account. If it's 11 | not your operation, please ignore this email and consider changing a 12 | password in case your account is stolen. 13 | {% endif %} 14 | Your token: {{ token }} 15 | Please copy this token to your browser to continue. 16 |
17 | -------------------------------------------------------------------------------- /migrations/versions/00fdded8fed4_enable_user_address.py: -------------------------------------------------------------------------------- 1 | """enable user address 2 | 3 | Revision ID: 00fdded8fed4 4 | Revises: 5 | Create Date: 2022-04-21 11:49:47.979440 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = "00fdded8fed4" 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.add_column("user", sa.Column("remote_addr", sa.String(), nullable=True)) 22 | # ### end Alembic commands ### 23 | 24 | 25 | def downgrade(): 26 | # ### commands auto generated by Alembic - please adjust! ### 27 | op.drop_column("user", "remote_addr") 28 | # ### end Alembic commands ### 29 | -------------------------------------------------------------------------------- /migrations/versions/dc9d5c6bed53_password_update.py: -------------------------------------------------------------------------------- 1 | """password update 2 | 3 | Revision ID: dc9d5c6bed53 4 | Revises: 00fdded8fed4 5 | Create Date: 2022-04-23 13:57:07.273581 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = "dc9d5c6bed53" 14 | down_revision = "00fdded8fed4" 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("user", sa.Column("password_update", sa.Float(), default=0)) 22 | # ### end Alembic commands ### 23 | 24 | 25 | def downgrade(): 26 | # ### commands auto generated by Alembic - please adjust! ### 27 | op.drop_column("user", "password_update") 28 | # ### end Alembic commands ### 29 | -------------------------------------------------------------------------------- /flemi/api/user/views.py: -------------------------------------------------------------------------------- 1 | from apiflask import APIBlueprint 2 | from flask.views import MethodView 3 | 4 | from ...models import User 5 | from ..decorators import can_edit 6 | from .schemas import PublicUserOutSchema 7 | 8 | user_bp = APIBlueprint("user", __name__) 9 | 10 | 11 | @user_bp.route("/user/", endpoint="user") 12 | class UserAPI(MethodView): 13 | @user_bp.output(PublicUserOutSchema) 14 | def get(self, user_id: int): 15 | """Return the public information of a certain user""" 16 | return User.query.get_or_404(user_id) 17 | 18 | @can_edit("profile") 19 | @user_bp.output(PublicUserOutSchema) 20 | def patch(self, user_id: int): 21 | """Lock or unlock a user""" 22 | user = User.query.get_or_404(user_id) 23 | user.locked = not user.locked 24 | return user 25 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | ci: 2 | autoupdate_schedule: monthly 3 | repos: 4 | - repo: https://github.com/asottile/pyupgrade 5 | rev: v3.15.0 6 | hooks: 7 | - id: pyupgrade 8 | args: ["--py38-plus"] 9 | - repo: https://github.com/asottile/reorder-python-imports 10 | rev: v3.12.0 11 | hooks: 12 | - id: reorder-python-imports 13 | args: ["--application-directories", "src"] 14 | files: flemi 15 | - repo: https://github.com/psf/black 16 | rev: 23.11.0 17 | hooks: 18 | - id: black 19 | files: flemi 20 | - repo: https://github.com/PyCQA/flake8 21 | rev: 6.1.0 22 | hooks: 23 | - id: flake8 24 | files: flemi 25 | - repo: https://github.com/pre-commit/pre-commit-hooks 26 | rev: v4.5.0 27 | hooks: 28 | - id: fix-byte-order-marker 29 | - id: trailing-whitespace 30 | - id: end-of-file-fixer 31 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ${{ matrix.os }} 8 | strategy: 9 | matrix: 10 | python: [ 11 | '3.8', 12 | '3.9', 13 | '3.10', 14 | ] 15 | os: [ubuntu-latest, windows-latest] 16 | steps: 17 | - uses: actions/checkout@v2 18 | - uses: pdm-project/setup-pdm@main 19 | name: Setup PDM 20 | with: 21 | python-version: ${{ matrix.python }} 22 | architecture: x64 23 | enable-pep582: true 24 | - name: Install dependencies 25 | run: pdm install --dev 26 | - name: Run pytest 27 | run: pdm run pytest 28 | - name: Generate xml coverage 29 | run: pdm run coverage xml 30 | - name: Upload coverage to codecov 31 | if: ${{ matrix.python == 3.9 && matrix.os == 'ubuntu-latest' }} 32 | uses: codecov/codecov-action@v2 33 | -------------------------------------------------------------------------------- /tests/test_group.py: -------------------------------------------------------------------------------- 1 | from flemi.models import db, Group 2 | from .base import Base 3 | 4 | 5 | class GroupTestCase(Base): 6 | def setUp(self) -> None: 7 | super().setUp() 8 | self.set_headers() 9 | 10 | def test_all_groups(self): 11 | g = Group(name="test") 12 | g2 = Group(name="test2", members=[self.test_user], private=True) 13 | db.session.add(g) 14 | db.session.add(g2) 15 | db.session.commit() 16 | 17 | resp = self.client.get( 18 | "/group/all", 19 | ) 20 | data = resp.get_json() 21 | self.assertEqual(resp.status_code, 200) 22 | self.assertEqual(data[0]["name"], "test") 23 | self.assertTrue(len(data) == 1) 24 | 25 | resp = self.client.get( 26 | "/group/all", 27 | headers=self.headers 28 | ) 29 | data = resp.get_json() 30 | print(len(data)) 31 | self.assertTrue(len(data) == 2) 32 | -------------------------------------------------------------------------------- /flemi/api/group/views.py: -------------------------------------------------------------------------------- 1 | from apiflask import APIBlueprint 2 | from flask import request 3 | 4 | from ...models import Group 5 | from ..auth.views import auth 6 | from ..auth.views import verify_token 7 | from .schemas import GroupOutSchema 8 | 9 | group_bp = APIBlueprint("group", __name__, url_prefix="/group") 10 | 11 | 12 | @group_bp.get("/all") 13 | @group_bp.output(GroupOutSchema(many=True)) 14 | def all(): 15 | token = request.headers.get("Authorization", "") 16 | public_groups = Group.query.filter_by(private=False).all() 17 | 18 | try: 19 | token = token[7:] 20 | user = verify_token(token) 21 | except IndexError: 22 | return public_groups 23 | 24 | if user is None: 25 | return public_groups 26 | else: 27 | return [g for g in Group.query.all() if not g.private or user in g.members] 28 | 29 | 30 | @group_bp.get("/me") 31 | @group_bp.auth_required(auth) 32 | @group_bp.output(GroupOutSchema(many=True)) 33 | def me(): 34 | return auth.current_user.groups 35 | -------------------------------------------------------------------------------- /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,flask_migrate 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 | [logger_flask_migrate] 38 | level = INFO 39 | handlers = 40 | qualname = flask_migrate 41 | 42 | [handler_console] 43 | class = StreamHandler 44 | args = (sys.stderr,) 45 | level = NOTSET 46 | formatter = generic 47 | 48 | [formatter_generic] 49 | format = %(levelname)-5.5s [%(name)s] %(message)s 50 | datefmt = %H:%M:%S 51 | -------------------------------------------------------------------------------- /tests/test_auth.py: -------------------------------------------------------------------------------- 1 | from .base import Base 2 | 3 | 4 | class AuthTestCase(Base): 5 | def test_get_token(self): 6 | resp = self.client.post( 7 | "/auth/login", 8 | json={ 9 | "username": "test", 10 | "password": "password", 11 | }, 12 | ) 13 | data = resp.get_json() 14 | self.assertEqual(resp.status_code, 200) 15 | self.assertTrue(data.get("auth_token").startswith("Bearer ")) 16 | 17 | def test_register(self): 18 | user_data = { 19 | "username": "test2", 20 | "name": "test", 21 | "password": "password", 22 | "email": "test2@example.com", 23 | } 24 | resp = self.client.post("/auth/register", json=user_data) 25 | self.assertEqual(resp.status_code, 200) 26 | 27 | user_data["username"] = "test" 28 | resp = self.client.post("/auth/register", json=user_data) 29 | self.assertEqual(resp.status_code, 400) 30 | 31 | user_data["username"] = "test3" 32 | user_data["email"] = "test@example.com" 33 | resp = self.client.post("/auth/register", json=user_data) 34 | self.assertEqual(resp.status_code, 400) 35 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "flemi-api" 3 | version = "" 4 | description = "" 5 | authors = [ 6 | {name = "rice0208", email = "riceforever0208@outlook.com"}, 7 | {name = "andyzhou", email = "andyforever0108@outlook.com"}, 8 | ] 9 | dependencies = [ 10 | "apiflask~=1.0.0", 11 | "flask-sqlalchemy>=2.5.1", 12 | "flask-migrate>=3.1.0", 13 | "authlib>=1.0.1", 14 | "bleach>=5.0.0", 15 | "faker>=13.3.5", 16 | "flask-cors>=3.0.10", 17 | "rich>=12.2.0", 18 | "halo>=0.0.31", 19 | "python-dotenv>=0.20.0", 20 | ] 21 | requires-python = ">=3.8" 22 | license = {text = "MIT"} 23 | [project.optional-dependencies] 24 | 25 | [tool.pdm] 26 | [tool.pdm.dev-dependencies] 27 | dev = [ 28 | "pytest>=7.1.2", 29 | "pytest-cov>=3.0.0", 30 | "pytest-xdist>=2.5.0", 31 | "mypy>=0.950", 32 | "flake8>=4.0.1", 33 | "black>=22.6.0", 34 | "pre-commit>=2.20.0", 35 | ] 36 | postgres = [ 37 | "psycopg2-binary>=2.9.3", 38 | ] 39 | 40 | 41 | [build-system] 42 | requires = ["pdm-pep517>=1.0.0"] 43 | build-backend = "pdm.pep517.api" 44 | 45 | [tool.pytest.ini_options] 46 | addopts = " -p no:warnings -n auto --cov" 47 | testpaths = ["tests"] 48 | 49 | [tool.coverage.run] 50 | branch = true 51 | include = ["flemi/api/*.py"] 52 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Flemi API 2 | 3 | > **Tips:** This repository is still under construction. 4 | 5 | This project is the new version of z-t-y/Flog. 6 | 7 | This written is written to be the back-end of this website project, there's also a front-end 8 | project of Flemi (using React.js). 9 | 10 | ## How to install 11 | 12 | Flemi API uses [PDM](https://github.com/pdm-project/pdm) to manage its dependencies, 13 | so you should install PDM first: 14 | 15 | ```powershell 16 | pip install pipx 17 | pipx install pdm 18 | ``` 19 | 20 | then install with PDM: 21 | 22 | ```powershell 23 | pdm install 24 | ``` 25 | 26 | To initialize Flemi API, you must make the database and administrator ready with command: 27 | 28 | ```powershell 29 | flask deploy 30 | flask create-admin 31 | ``` 32 | 33 | If you want to generate fake data for testing, you should use command `flask forge` after 34 | running the command above. 35 | 36 | Then you can run our Flemi API. In most cases just use `flask run`, but if you use servers 37 | like PythonAnywhere, Heroku, etc. Read its docs and go on. 38 | 39 | ## Credits 40 | 41 | Flemi project is created by [@z-t-y](https://github.com/z-t-y). Now maintained by [helloflask/floggers](https://github.com/orgs/helloflask/teams/floggers). See contributors for more information. 42 | -------------------------------------------------------------------------------- /tests/base.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | from unittest import TestCase 3 | from flemi import create_app 4 | from flemi.extensions import db 5 | from flemi.models import User 6 | 7 | 8 | class Base(TestCase): 9 | def setUp(self) -> None: 10 | self.app = create_app("testing") 11 | self.context = self.app.test_request_context() 12 | self.client = self.app.test_client() 13 | self.context.push() 14 | db.drop_all() 15 | db.create_all() 16 | self.test_user = User(name="test", username="test", email="test@example.com") 17 | self.test_user.set_password("password") 18 | db.session.add(self.test_user) 19 | db.session.commit() 20 | 21 | def tearDown(self) -> None: 22 | db.session.remove() 23 | db.drop_all() 24 | self.context.pop() 25 | 26 | def set_headers( 27 | self, username: t.Optional[str] = "test", password: t.Optional[str] = "password" 28 | ): 29 | resp = self.client.post( 30 | "/auth/login", 31 | json={ 32 | "username": username, 33 | "password": password, 34 | }, 35 | ) 36 | token = resp.get_json()["auth_token"] 37 | self.headers = { 38 | "Authorization": token, 39 | "Accept": "application/json", 40 | } 41 | -------------------------------------------------------------------------------- /flemi/api/me/schemas.py: -------------------------------------------------------------------------------- 1 | from marshmallow import Schema 2 | from marshmallow import validate 3 | from marshmallow.fields import Email 4 | from marshmallow.fields import Function 5 | from marshmallow.fields import String 6 | from marshmallow.fields import Url 7 | 8 | 9 | class PrivateUserOutSchema(Schema): 10 | avatar_url = Function(lambda obj: obj.avatar_url()) 11 | 12 | class Meta: 13 | fields = ( 14 | "id", 15 | "email", 16 | "username", 17 | "name", 18 | "coins", 19 | "experience", 20 | "location", 21 | "about_me", 22 | "confirmed", 23 | "blocked", 24 | "member_since", 25 | "last_seen", 26 | "is_admin", 27 | "clicks", 28 | "clicks_today", 29 | "avatar_url", 30 | ) 31 | 32 | 33 | class BasicProfileEditSchema(Schema): 34 | username = String( 35 | required=True, validate=validate.Regexp(r"^[A-Za-z]([A-Za-z0-9_\-.]){2,15}$", 0) 36 | ) 37 | email = Email(required=True) 38 | name = String(required=True, validate=validate.Length(1, 64)) 39 | 40 | 41 | class AvatarEditSchema(Schema): 42 | avatar_url = Url(required=True) 43 | 44 | 45 | class AboutEditSchema(Schema): 46 | about_me = String(validate=validate.Length(0, 250)) 47 | -------------------------------------------------------------------------------- /flemi/commands/cli.py: -------------------------------------------------------------------------------- 1 | from random import randint 2 | 3 | from rich import print 4 | 5 | 6 | def confirm(prompt, otherwise): 7 | while True: 8 | print(prompt, end="") 9 | answer = input(" [Y/n] ") 10 | if answer in "YyNn" and len(answer) == 1: 11 | if answer in "Nn": 12 | print("[yellow]WARNING[/yellow]: operation cancelled by user.\n") 13 | otherwise() 14 | else: 15 | break 16 | else: 17 | print( 18 | "[red]Fatal[/red]: please choose [green]Y[/green] or [green]n[/green]" 19 | ) 20 | continue 21 | 22 | 23 | def check(prompt): 24 | while True: 25 | print(prompt, end="") 26 | answer = input(" [Y/n] ") 27 | if answer in "YyNn" and len(answer) == 1: 28 | return answer in "Yy" 29 | print("[red]Fatal[/red]: please choose [green]Y[/green] or [green]n[/green]") 30 | 31 | 32 | def int_input(prompt: str, default=None, auto_default: bool = False): 33 | while True: 34 | print(prompt, end="") 35 | answer = input("") 36 | try: 37 | return int(answer) 38 | except Exception: 39 | if (default is not None) and (auto_default or answer == ""): 40 | return default 41 | print(f"[red]Fatal[/red]: integer is required (e.g. {randint(3, 99)})") 42 | -------------------------------------------------------------------------------- /tests/test_me.py: -------------------------------------------------------------------------------- 1 | from flemi.models import User 2 | from .base import Base 3 | 4 | 5 | class MeTestCase(Base): 6 | def setUp(self) -> None: 7 | super().setUp() 8 | self.set_headers() 9 | 10 | def test_get_profile(self): 11 | resp = self.client.get( 12 | "/me", 13 | headers=self.headers, 14 | ) 15 | self.assertEqual(resp.status_code, 200) 16 | self.assertEqual(resp.get_json()["username"], "test") 17 | 18 | def test_edit_basic(self): 19 | resp = self.client.put( 20 | "/me/edit/basic", 21 | json={"name": "new name"}, 22 | headers=self.headers, 23 | ) 24 | self.assertEqual(resp.status_code, 200) 25 | self.assertEqual(User.query.filter_by(username="test").first().name, "new name") 26 | 27 | def test_edit_avatar(self): 28 | resp = self.client.put( 29 | "/me/edit/avatar", 30 | json={"avatar_url": "https://example.com"}, 31 | headers=self.headers, 32 | ) 33 | self.assertEqual(resp.status_code, 200) 34 | self.assertEqual( 35 | User.query.filter_by(username="test").first().custom_avatar_url, 36 | "https://example.com", 37 | ) 38 | 39 | def test_edit_about_me(self): 40 | resp = self.client.put( 41 | "/me/edit/about", 42 | json={"about_me": "nothing special"}, 43 | headers=self.headers, 44 | ) 45 | self.assertEqual(resp.status_code, 200) 46 | self.assertEqual( 47 | User.query.filter_by(username="test").first().about_me, 48 | "nothing special", 49 | ) 50 | -------------------------------------------------------------------------------- /flemi/api/decorators.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | 3 | from apiflask import abort 4 | 5 | from ..models import Column 6 | from ..models import Comment 7 | from ..models import Group 8 | from ..models import Post 9 | from ..models import User 10 | from .auth.views import auth 11 | 12 | 13 | def can_edit(permission_type: str): 14 | def decorator(f): 15 | @wraps(f) 16 | @auth.login_required 17 | def decorated_function(*args, **kwargs): 18 | permitted = False 19 | if auth.current_user is not None: 20 | if args[1] is not None: 21 | permitted = check_permission(permission_type, args[1]) 22 | if not permitted: 23 | abort(403) 24 | return f(*args, **kwargs) 25 | 26 | return decorated_function 27 | 28 | return decorator 29 | 30 | 31 | def check_permission(permission_type: str, model_id: int) -> bool: 32 | permitted = False 33 | current_user: User = auth.current_user # type: ignore 34 | if permission_type == "profile": 35 | permitted = auth.current_user == User.query.get_or_404(model_id) 36 | elif permission_type == "post": 37 | permitted = Post.query.get_or_404(model_id) in current_user.posts 38 | elif permission_type == "comment": 39 | permitted = Comment.query.get_or_404(model_id) in current_user.comments 40 | elif permission_type == "column": 41 | permitted = Column.query.get_or_404(model_id) in current_user.columns 42 | elif permission_type == "group": 43 | permitted = auth.current_user == Group.query.get_or_404(model_id).manager 44 | return permitted 45 | 46 | 47 | def permission_required(f): 48 | @wraps(f) 49 | @auth.login_required 50 | def decorated_function(*args, **kwargs): 51 | if (auth.current_user is None) or auth.current_user.locked: 52 | abort(403) 53 | return f(*args, **kwargs) 54 | 55 | return decorated_function 56 | -------------------------------------------------------------------------------- /flemi/__init__.py: -------------------------------------------------------------------------------- 1 | import importlib as il 2 | import os 3 | 4 | from apiflask import APIFlask 5 | from flask import Flask 6 | from flask_cors import CORS 7 | from werkzeug.middleware.proxy_fix import ProxyFix 8 | 9 | from .commands import register_commands 10 | from .extensions import db 11 | from .extensions import ma 12 | from .extensions import migrate 13 | from .models import Column 14 | from .models import Group 15 | from .models import Message 16 | from .models import Post 17 | from .models import User 18 | from .settings import config 19 | from .utils import get_all_remote_addr 20 | 21 | 22 | def create_app(config_name=None) -> Flask: 23 | if config_name is None: 24 | config_name = os.getenv("FLASK_CONFIG", "development") 25 | app = APIFlask("flemi") 26 | app.wsgi_app = ProxyFix(app.wsgi_app, x_host=1) 27 | 28 | @app.get("/") 29 | def index(): 30 | """ 31 | help API of the app 32 | """ 33 | return {"/": "version 4.x of flemi web API"} 34 | 35 | register_config(app, config_name) 36 | register_blueprints(app) 37 | register_extensions(app) 38 | register_commands(app, db) 39 | register_context(app) 40 | return app 41 | 42 | 43 | def register_config(app: Flask, config_name: str) -> None: 44 | app.config.from_object(config[config_name]) 45 | 46 | 47 | def register_extensions(app: Flask) -> None: 48 | db.init_app(app=app) 49 | migrate.init_app(app=app, db=db) 50 | ma.init_app(app) 51 | 52 | 53 | def register_blueprints(app: Flask) -> None: 54 | for mod_name in ("auth", "me", "post", "user", "group"): 55 | mod = il.import_module(f".api.{mod_name}.views", "flemi") 56 | blueprint = getattr(mod, f"{mod_name}_bp") 57 | CORS(blueprint) 58 | app.register_blueprint(blueprint) 59 | 60 | 61 | def register_context(app: Flask) -> None: 62 | @app.shell_context_processor 63 | def make_shell_context(): 64 | return dict( 65 | db=db, 66 | User=User, 67 | Post=Post, 68 | Group=Group, 69 | Message=Message, 70 | Column=Column, 71 | get_all_remote_addr=get_all_remote_addr, 72 | ) 73 | -------------------------------------------------------------------------------- /flemi/api/me/views.py: -------------------------------------------------------------------------------- 1 | from apiflask import APIBlueprint 2 | 3 | from ...extensions import auth 4 | from ...extensions import db 5 | from ...models import User 6 | from .schemas import AboutEditSchema 7 | from .schemas import AvatarEditSchema 8 | from .schemas import BasicProfileEditSchema 9 | from .schemas import PrivateUserOutSchema 10 | 11 | 12 | me_bp = APIBlueprint("me", __name__, url_prefix="/me") 13 | 14 | 15 | @me_bp.get("") 16 | @me_bp.auth_required(auth) 17 | @me_bp.output(PrivateUserOutSchema) 18 | def self_profile(): 19 | """ 20 | profile of the current user 21 | """ 22 | return auth.current_user 23 | 24 | 25 | @me_bp.get("/help") 26 | def help(): # pragma: no cover 27 | """ 28 | help API of blueprint 29 | """ 30 | return { 31 | "/": "profile of the current user", 32 | "/edit/basic": "edit username, email and name", 33 | "/edit/avatar": "change avatar", 34 | "/help": "help API of blueprint", 35 | } 36 | 37 | 38 | @me_bp.put("/edit/basic") 39 | @me_bp.auth_required(auth) 40 | @me_bp.input(BasicProfileEditSchema(partial=True)) 41 | def edit_basic(data): 42 | """ 43 | edit username, email and name 44 | """ 45 | me: User = auth.current_user 46 | for key, value in data.items(): 47 | if ( 48 | key == "username" and User.query.filter_by(username=value).first() 49 | ): # pragma: no cover 50 | return {"message": "username already exists"}, 400 51 | if ( 52 | key == "email" and User.query.filter_by(email=value).first() 53 | ): # pragma: no cover 54 | return {"message": "email already exists"}, 400 55 | setattr(me, key, value) 56 | 57 | db.session.commit() 58 | return {"message": "ok"}, 200 59 | 60 | 61 | @me_bp.put("/edit/avatar") 62 | @me_bp.auth_required(auth) 63 | @me_bp.input(AvatarEditSchema) 64 | def edit_avatar(data): 65 | """ 66 | change avatar 67 | """ 68 | auth.current_user.custom_avatar_url = data["avatar_url"] 69 | db.session.commit() 70 | return {"message": "ok"}, 200 71 | 72 | 73 | @me_bp.put("/edit/about") 74 | @me_bp.auth_required(auth) 75 | @me_bp.input(AboutEditSchema) 76 | def edit_about_me(data): 77 | """ 78 | edit self description 79 | """ 80 | auth.current_user.about_me = data["about_me"] 81 | db.session.commit() 82 | return {"message": "ok"}, 200 83 | -------------------------------------------------------------------------------- /flemi/api/post/schemas.py: -------------------------------------------------------------------------------- 1 | from apiflask import Schema 2 | from apiflask.fields import Boolean 3 | from apiflask.fields import Integer 4 | from apiflask.fields import List 5 | from apiflask.fields import Nested 6 | from apiflask.fields import String 7 | from marshmallow.fields import Url 8 | 9 | from ...extensions import ma 10 | from ..user.schemas import PublicUserOutSchema 11 | 12 | 13 | class CommentInSchema(Schema): 14 | body = String(required=True) 15 | post_id = Integer(required=True) 16 | reply_id = Integer() 17 | 18 | 19 | class CommentOutSchema(Schema): 20 | id = Integer() 21 | body = String() 22 | author = Nested(PublicUserOutSchema) 23 | post = Nested( 24 | lambda: PostOutSchema( 25 | only=( 26 | "id", 27 | "title", 28 | "self", 29 | ) 30 | ) 31 | ) 32 | replying = Nested(lambda: CommentOutSchema(exclude=("replying",))) 33 | self = ma.URLFor(".comment", values=dict(comment_id="")) 34 | 35 | 36 | class ColumnInSchema(Schema): 37 | name = String() 38 | post_ids = List(Integer()) 39 | 40 | 41 | class ColumnOutSchema(Schema): 42 | id = Integer() 43 | name = String() 44 | author = Nested(PublicUserOutSchema) 45 | posts = List(Nested(lambda: PostOutSchema(only=("id", "title", "self")))) 46 | self = ma.URLFor(".column", values=dict(column_id="")) 47 | 48 | 49 | class PostInSchema(Schema): 50 | title = String(required=True) 51 | content = String(required=True) 52 | private = Boolean(default=False) 53 | column_ids = List(Integer()) 54 | 55 | 56 | class PostOutSchema(Schema): 57 | id = Integer() 58 | title = String() 59 | content = String() 60 | coins = Integer() 61 | private = Boolean() 62 | author = Nested(PublicUserOutSchema) 63 | comments = List(Nested(CommentOutSchema, exclude=("post",))) 64 | columns = List(Nested(ColumnOutSchema, exclude=("posts", "author"))) 65 | self = ma.URLFor("post.post", values=dict(post_id="")) 66 | 67 | 68 | class PostsSchema(Schema): 69 | posts = Nested(PostOutSchema(many=True)) 70 | prev = Url() 71 | next = Url() 72 | total = Integer() 73 | 74 | 75 | class CommentOutSchema(Schema): 76 | id = Integer() 77 | body = String() 78 | author = Nested(PublicUserOutSchema) 79 | post = Nested( 80 | lambda: PostOutSchema( 81 | only=( 82 | "id", 83 | "title", 84 | "self", 85 | ) 86 | ) 87 | ) 88 | replying = Nested(lambda: CommentOutSchema(exclude=("replying",))) 89 | self = ma.URLFor(".comment", values=dict(comment_id="")) 90 | -------------------------------------------------------------------------------- /tests/test_post.py: -------------------------------------------------------------------------------- 1 | from .base import Base 2 | from flemi import fakes 3 | from flemi.models import Post, Column 4 | 5 | 6 | class PostTestCase(Base): 7 | def setUp(self): 8 | super().setUp() 9 | fakes.posts(count=10, private=False) 10 | fakes.posts(count=2, private=True) 11 | fakes.columns(3) 12 | self.set_headers() 13 | 14 | def test_get_post(self): 15 | resp = self.client.get("/post/1") 16 | self.assertEqual(resp.status_code, 200) 17 | self.assertEqual(resp.json["title"], Post.query.get(1).title) 18 | 19 | def test_get_posts(self): 20 | resp = self.client.get("/posts?limit=3&offset=0") 21 | self.assertEqual(resp.status_code, 200) 22 | self.assertEqual(len(resp.json["posts"]), 3) 23 | self.assertEqual(resp.json["prev"], "") 24 | 25 | resp = self.client.get("/posts?limit=4&offset=8") 26 | self.assertEqual(len(resp.json["posts"]), 2) 27 | self.assertEqual(resp.json["next"], "") 28 | self.assertEqual(resp.json["posts"][0]["title"], Post.query.get(9).title) 29 | 30 | def test_create_post(self): 31 | resp = self.client.post( 32 | "/post", headers=self.headers, json={"title": "lorem", "content": "ipsum"} 33 | ) 34 | self.assertEqual(resp.status_code, 201) 35 | resp = self.client.post( 36 | "/post", 37 | headers=self.headers, 38 | json={"title": "post2", "content": "post2", "column_ids": [1, 2]}, 39 | ) 40 | p = Post.query.filter_by(title="post2").first() 41 | self.assertEqual(resp.status_code, 201) 42 | self.assertIn(Column.query.get(1), p.columns) 43 | self.assertIn(Column.query.get(2), p.columns) 44 | 45 | def test_update_post(self): 46 | resp = self.client.post( 47 | "/post", headers=self.headers, json={"title": "lorem", "content": "ipsum"} 48 | ) 49 | p = Post.query.filter_by(title="lorem").first() 50 | resp = self.client.put( 51 | f"/post/{p.id}", headers=self.headers, json={"title": "new"} 52 | ) 53 | self.assertEqual(resp.status_code, 200) 54 | self.assertEqual(p.title, "new") 55 | resp = self.client.put( 56 | f"/post/{p.id}", headers=self.headers, json={"column_ids": [3]} 57 | ) 58 | self.assertEqual(resp.status_code, 200) 59 | self.assertNotIn(Column.query.get(1), p.columns) 60 | self.assertIn(Column.query.get(3), p.columns) 61 | 62 | def test_delete_post(self): 63 | self.client.post( 64 | "/post", headers=self.headers, json={"title": "lorem", "content": "ipsum"} 65 | ) 66 | p = Post.query.filter_by(title="lorem").first() 67 | resp = self.client.delete( 68 | f"/post/{p.id}", 69 | headers=self.headers, 70 | ) 71 | self.assertEqual(resp.status_code, 204) 72 | -------------------------------------------------------------------------------- /migrations/env.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from logging.config import fileConfig 3 | 4 | from flask import current_app 5 | 6 | from alembic import context 7 | 8 | # this is the Alembic Config object, which provides 9 | # access to the values within the .ini file in use. 10 | config = context.config 11 | 12 | # Interpret the config file for Python logging. 13 | # This line sets up loggers basically. 14 | fileConfig(config.config_file_name) 15 | logger = logging.getLogger("alembic.env") 16 | 17 | # add your model's MetaData object here 18 | # for 'autogenerate' support 19 | # from myapp import mymodel 20 | # target_metadata = mymodel.Base.metadata 21 | config.set_main_option( 22 | "sqlalchemy.url", 23 | str(current_app.extensions["migrate"].db.get_engine().url).replace("%", "%%"), 24 | ) 25 | target_metadata = current_app.extensions["migrate"].db.metadata 26 | 27 | # other values from the config, defined by the needs of env.py, 28 | # can be acquired: 29 | # my_important_option = config.get_main_option("my_important_option") 30 | # ... etc. 31 | 32 | 33 | def run_migrations_offline(): 34 | """Run migrations in 'offline' mode. 35 | 36 | This configures the context with just a URL 37 | and not an Engine, though an Engine is acceptable 38 | here as well. By skipping the Engine creation 39 | we don't even need a DBAPI to be available. 40 | 41 | Calls to context.execute() here emit the given string to the 42 | script output. 43 | 44 | """ 45 | url = config.get_main_option("sqlalchemy.url") 46 | context.configure(url=url, target_metadata=target_metadata, literal_binds=True) 47 | 48 | with context.begin_transaction(): 49 | context.run_migrations() 50 | 51 | 52 | def run_migrations_online(): 53 | """Run migrations in 'online' mode. 54 | 55 | In this scenario we need to create an Engine 56 | and associate a connection with the context. 57 | 58 | """ 59 | 60 | # this callback is used to prevent an auto-migration from being generated 61 | # when there are no changes to the schema 62 | # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html 63 | def process_revision_directives(context, revision, directives): 64 | if getattr(config.cmd_opts, "autogenerate", False): 65 | script = directives[0] 66 | if script.upgrade_ops.is_empty(): 67 | directives[:] = [] 68 | logger.info("No changes in schema detected.") 69 | 70 | connectable = current_app.extensions["migrate"].db.get_engine() 71 | 72 | with connectable.connect() as connection: 73 | context.configure( 74 | connection=connection, 75 | target_metadata=target_metadata, 76 | process_revision_directives=process_revision_directives, 77 | **current_app.extensions["migrate"].configure_args 78 | ) 79 | 80 | with context.begin_transaction(): 81 | context.run_migrations() 82 | 83 | 84 | if context.is_offline_mode(): 85 | run_migrations_offline() 86 | else: 87 | run_migrations_online() 88 | -------------------------------------------------------------------------------- /flemi/utils.py: -------------------------------------------------------------------------------- 1 | from os.path import exists 2 | from os.path import join 3 | from urllib.parse import urljoin 4 | from urllib.parse import urlparse 5 | 6 | from bleach import clean 7 | from flask import current_app 8 | from flask import redirect 9 | from flask import request 10 | from flask import url_for 11 | from werkzeug.utils import secure_filename 12 | 13 | from .models import db 14 | from .models import Image 15 | from .models import User 16 | 17 | 18 | def lower_username(username: str) -> str: 19 | """Returns lowered username""" 20 | return username.strip().lower().replace(" ", "") 21 | 22 | 23 | def is_safe_url(target): 24 | """Check if target url is safe""" 25 | ref_url = urlparse(request.host_url) 26 | test_url = urlparse(urljoin(request.host_url, target)) 27 | return test_url.scheme in ("http", "https") and ref_url.netloc == test_url.netloc 28 | 29 | 30 | def redirect_back(default="main.main", **kwargs): 31 | """Redirect back""" 32 | for target in request.args.get("next"), request.referrer: 33 | if not target: 34 | continue 35 | if is_safe_url(target): 36 | return redirect(target) 37 | return redirect(url_for(default, **kwargs)) 38 | 39 | 40 | def get_image_path_and_url(image_obj, current_user) -> dict: 41 | filename = image_obj.filename 42 | # add the current user's username to the filename of the image 43 | filename = current_user.username + "_" + filename 44 | # find the position of the last dot in the filename 45 | last_dot_in_filename = filename.rfind(".") 46 | # get the filename(without extension) and the extension 47 | filename_without_ext = filename[:last_dot_in_filename] 48 | extension = filename[last_dot_in_filename + 1 :] # noqa 49 | if extension not in ["jpg", "gif", "png", "jpeg", "jpg"]: 50 | return dict(error="Images only!") 51 | # get the absolute image path for the new image 52 | image_path = join(current_app.config["UPLOAD_DIRECTORY"], filename) 53 | # deal with duplicated filenames 54 | while exists(image_path): 55 | filename_without_ext += "_" # add underscores after the existed filename 56 | image_path = join( 57 | current_app.config["UPLOAD_DIRECTORY"], 58 | filename_without_ext + "." + extension, 59 | ) 60 | # get final filename 61 | filename = filename_without_ext + "." + extension 62 | filename = secure_filename(filename) 63 | current_app.logger.info(f"Upload file {filename} saved.") 64 | image_obj.save(image_path) 65 | # commit the image to the db 66 | image = Image(filename=filename, author=current_user) 67 | db.session.add(image) 68 | db.session.commit() 69 | url = image.url() 70 | current_app.logger.info(f"Upload file url: {url}") 71 | return {"image_url": url, "filename": image.filename, "image_id": image.id} 72 | 73 | 74 | def clean_html(content: str) -> str: 75 | return clean( 76 | content, 77 | tags=current_app.config["FLEMI_ALLOWED_TAGS"], 78 | attributes=current_app.config["FLEMI_ALLOWED_HTML_ATTRIBUTES"], 79 | strip_comments=True, 80 | ) 81 | 82 | 83 | def get_all_remote_addr() -> None: # console 84 | users = User.query.all() 85 | for user in users: 86 | uid = str(user.id).rjust(5) 87 | username = user.username.ljust(32) 88 | addr = (user.remote_addr or " ").ljust(16) 89 | print(uid + " " + username + addr) 90 | -------------------------------------------------------------------------------- /flemi/api/auth/views.py: -------------------------------------------------------------------------------- 1 | from time import time 2 | 3 | from apiflask import abort 4 | from apiflask import APIBlueprint 5 | from authlib.jose import jwt 6 | from authlib.jose.errors import JoseError 7 | from flask import current_app 8 | 9 | from ...extensions import auth 10 | from ...extensions import db 11 | from ...models import User 12 | from .schemas import LoginSchema 13 | from .schemas import RegisterSchema 14 | 15 | 16 | # create authentication blueprint for API v4 17 | auth_bp = APIBlueprint("auth", __name__, url_prefix="/auth") 18 | 19 | 20 | @auth.verify_token 21 | def verify_token(token: str): 22 | """ 23 | verify token and returns user 24 | """ 25 | try: 26 | data = jwt.decode(token.encode("ascii"), current_app.config["SECRET_KEY"]) 27 | if data.get("time") + 3600 * 24 * 30 > time(): 28 | user = User.query.get(data.get("uid")) # None if user does not exist 29 | if ( 30 | user 31 | and user.password_update 32 | and user.password_update > data.get("time") 33 | ): 34 | return None 35 | else: 36 | raise JoseError("Token expired") 37 | except JoseError: 38 | return None 39 | else: 40 | user.ping() 41 | return user 42 | 43 | 44 | @auth_bp.get("/") 45 | @auth_bp.get("/help") 46 | def help(): # pragma: no cover 47 | """ 48 | help API of blueprint 49 | """ 50 | return { 51 | "/help": "help API of blueprint", 52 | "/login": "sign in and get API auth token", 53 | "/register": "create a new account", 54 | } 55 | 56 | 57 | @auth_bp.post("/login") 58 | @auth_bp.input(LoginSchema) 59 | def login(data): 60 | """ 61 | sign in and get API auth token 62 | """ 63 | username, password = data["username"], data["password"] 64 | user = User.query.filter_by(username=username).first() 65 | email = User.query.filter_by(email=username).first() 66 | if not (user or email): 67 | abort(404) 68 | 69 | user = user or email 70 | if not user.verify_password(password): 71 | abort(403) 72 | return {"auth_token": "Bearer " + user.auth_token()}, 200 73 | 74 | 75 | @auth_bp.post("/register") 76 | @auth_bp.input(RegisterSchema) 77 | def register(data): 78 | """ 79 | create a new account 80 | """ 81 | u = User() 82 | for key, value in data.items(): 83 | if key == "password": 84 | u.set_password(value) 85 | continue 86 | elif key == "username" and User.query.filter_by(username=value).first(): 87 | return {"message": "username already exists"}, 400 88 | elif key == "email" and User.query.filter_by(email=value).first(): 89 | return {"message": "email already exists"}, 400 90 | setattr(u, key, value) 91 | 92 | db.session.add(u) 93 | db.session.commit() 94 | return {"message": "ok"} 95 | 96 | 97 | # TODO: finish these after send_mail has been implemented. 98 | # @auth_bp.get("/confirm/send") 99 | # @auth_bp.auth_required 100 | # def send_confirmation(): 101 | # me: User = g.current_user 102 | # token = me.gen_email_verify_token() 103 | # send_email( 104 | # [me.email], 105 | # "Confirm Your Account", 106 | # "confirm", 107 | # username=me.username, 108 | # token=token, 109 | # mode=2, 110 | # ) 111 | # return {"message": "ok"} 112 | 113 | 114 | # @auth_bp.get("/confirm") 115 | # @auth.login_required 116 | # @auth_bp.input(EmailConfirmationSchema) 117 | # def confirm(data): 118 | # me: User = g.current_user 119 | # token = data["token"] 120 | # if me.verify_email_token(token): 121 | # return {"message": "ok"} 122 | # else: 123 | # return {"message": "failed"} 124 | -------------------------------------------------------------------------------- /flemi/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | MIT License 3 | Copyright (c) 2020 Andy Zhou 4 | """ 5 | import os 6 | from os.path import abspath 7 | from os.path import dirname 8 | from os.path import join 9 | 10 | 11 | def generate_sqlite_filename(filename: str): 12 | basedir = os.path.abspath(os.path.dirname(os.path.dirname(__file__))) 13 | return "sqlite:///" + os.path.join(basedir, f"{filename}.sqlite3") 14 | 15 | 16 | class Base: 17 | DEBUG = False 18 | TESTING = False 19 | SSL_REDIRECT = False 20 | 21 | SECRET_KEY = os.getenv("SECRET_KEY", "hard-to-guess") 22 | 23 | SQLALCHEMY_ENGINE_OPTIONS = {"pool_recycle": 10, "pool_size": 30} 24 | # SQLALCHEMY_POOL_SIZE = 30 25 | SQLALCHEMY_TRACK_MODIFICATIONS = False 26 | 27 | MAIL_SERVER = os.getenv("MAIL_SERVER", "smtp.gmail.com") 28 | MAIL_PORT = 25 29 | MAIL_USE_TLS = False 30 | MAIL_USERNAME = os.getenv("FLEMI_EMAIL", "flemi_admin@example.com") 31 | MAIL_PASSWORD = os.getenv("FLEMI_EMAIL_PASSWORD", "flemi_email_password") 32 | MAIL_DEFAULT_SENDER = os.getenv( 33 | "DEFAULT_EMAIL_SENDER", "flemi " 34 | ) 35 | 36 | HOT_POST_COIN = 7 37 | HOT_COLUMN_COIN = 40 38 | 39 | FLEMI_ADMIN = os.getenv("FLEMI_ADMIN", "flemi_admin") 40 | FLEMI_ADMIN_EMAIL = os.getenv("FLEMI_ADMIN_EMAIL", MAIL_USERNAME) 41 | FLEMI_ADMIN_PASSWORD = os.getenv("FLEMI_ADMIN_PASSWORD", "hydrogen") 42 | 43 | LOCALES = {"en_US": "English(US)", "zh_Hans_CN": "简体中文"} 44 | 45 | UPLOAD_DIRECTORY = join(dirname(dirname(abspath(__file__))), "images/") 46 | MAX_CONTENT_LENGTH = 1 * 1024 * 1024 47 | 48 | CKEDITOR_HEIGHT = 800 49 | CKEDITOR_WIDTH = 1024 50 | CKEDITOR_FILE_UPLOADER = "image.upload" 51 | CKEDITOR_ENABLE_CSRF = True 52 | 53 | # Specially configured for pythonanywhere 54 | SQLALCHEMY_ENGINE_OPTIONS = {"pool_recycle": 280} 55 | 56 | # Allowed tags for posts 57 | # fmt: off 58 | FLEMI_ALLOWED_TAGS = [ 59 | "p", "hr", "h1", "h2", "h3", "h4", "a", 60 | "img", "strong", "em", "s", "i", "b", 61 | "div", "span", "br", "ol", "ul", "li", 62 | "table", "thead", "tbody", "th", "td", "tr", 63 | "pre", "code", "iframe", "sub", "sup", 64 | "quote", "blockquote", "small" 65 | ] 66 | 67 | FLEMI_ALLOWED_HTML_ATTRIBUTES = [ 68 | "href", "src", "style", "class", 69 | "xmlns:xlink", "width", "height", "tabindex", 70 | "viewBox", "role", "focusable", "stroke-width", 71 | "id", "d", "lang", "alt" 72 | ] 73 | 74 | # fmt: on 75 | 76 | @classmethod 77 | def init_app(cls, app): 78 | pass 79 | 80 | 81 | class Production(Base): 82 | FLASK_CONFIG = "production" 83 | SQLALCHEMY_DATABASE_URI = os.getenv( 84 | "DATABASE_PROD", generate_sqlite_filename("data") 85 | ) 86 | 87 | @classmethod 88 | def init_app(cls, app): 89 | Base.init_app(app) 90 | 91 | import logging 92 | from logging.handlers import SMTPHandler 93 | 94 | credentials = None 95 | secure = None 96 | if getattr(cls, "ADMIN_EMAIL", None) is not None: 97 | credentials = (cls.FLEMI_ADMIN_EMAIL, cls.MAIL_PASSWORD) 98 | if getattr(cls, "MAIL_USE_TLS", None): 99 | secure = () 100 | mail_handler = SMTPHandler( 101 | mailhost=(cls.MAIL_SERVER, cls.MAIL_PORT), 102 | fromaddr=cls.MAIL_DEFAULT_SENDER, 103 | toaddrs=[cls.FLEMI_ADMIN_EMAIL], 104 | subject="Application Error", 105 | credentials=credentials, 106 | secure=secure, 107 | ) 108 | mail_handler.setLevel(logging.ERROR) 109 | app.logger.addHandler(mail_handler) 110 | 111 | 112 | class Development(Base): 113 | FLASK_CONFIG = "development" 114 | SQLALCHEMY_DATABASE_URI = os.getenv( 115 | "DATABASE_DEV", generate_sqlite_filename("data-dev") 116 | ) 117 | DEBUG = True 118 | MAIL_SUPPRESS_SEND = True 119 | 120 | 121 | class Test(Base): 122 | TESTING = True 123 | WTF_CSRF_ENABLED = False 124 | SQLALCHEMY_DATABASE_URI = os.getenv("DATABASE_TEST", "sqlite:///:memory:") 125 | 126 | 127 | config = { 128 | "production": Production, 129 | "development": Development, 130 | "testing": Test, 131 | } 132 | -------------------------------------------------------------------------------- /flemi/commands/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import click 4 | from flask import Flask 5 | from rich import print 6 | 7 | from .auth import register_auth_group 8 | from .cli import confirm 9 | 10 | 11 | def register_commands(app: Flask, db): # noqa: C901 12 | @app.cli.command() 13 | def test() -> None: 14 | """Run the unittests.""" 15 | os.system("pytest -v") 16 | 17 | @app.cli.command() 18 | def create_admin(): 19 | """Create administrator account""" 20 | from ..models import User 21 | 22 | username = app.config["FLEMI_ADMIN"] 23 | email = app.config["FLEMI_ADMIN_EMAIL"] 24 | password = app.config["FLEMI_ADMIN_PASSWORD"] 25 | 26 | print("\ncreating administrator account ...") 27 | print("[yellow]username[/yellow]: [green]%s[/green]" % username) 28 | print("[yellow]email[/yellow]: [green]%s[/green]" % email) 29 | 30 | if ( 31 | User.query.filter_by(email=email).count() 32 | + User.query.filter_by(username=username).count() 33 | == 0 34 | ): 35 | admin = User(username=username, email=email, name=username, confirmed=True) 36 | admin.set_password(password) 37 | admin.is_admin = True 38 | db.session.add(admin) 39 | db.session.commit() 40 | print("[green]Success![/green]\n") 41 | else: 42 | print( 43 | f"\n[red]Fatal[/red]: user matching email ([green]{email}[/green]) " 44 | f"or username ([green]{username}[/green]) excceeded " 45 | "(limit: [magenta]1[/magenta])\n" 46 | ) 47 | 48 | @app.cli.command() 49 | @click.option("--users", default=20, help="Generates fake users") 50 | @click.option("--posts", default=200, help="Generates fake posts") 51 | @click.option("--comments", default=100, help="Generates fake comments") 52 | @click.option("--notifications", default=10, help="Generates fake notifications") 53 | @click.option("--groups", default=20, help="Generates fake groups") 54 | @click.option("--columns", default=20, help="Generate fake columns") 55 | @click.option("--messages", default=300, help="Generate fake messages") 56 | def forge( 57 | users, 58 | posts, 59 | comments, 60 | notifications, 61 | groups, 62 | columns, 63 | messages, 64 | ): 65 | """Generates fake data""" 66 | print( 67 | "\n[yellow]WARNING[/yellow]: the forge command is for development use. " 68 | + "it will generate fake data which may mess up your database with a lot of " 69 | + "[red]bullshit[/red], and deleting them from your database is pretty hard." 70 | ) 71 | confirm("would you like to continue?", otherwise=exit) 72 | 73 | print("\ngenerating fake data ...") 74 | from .. import fakes as fake 75 | 76 | fake.users(users) 77 | fake.posts(posts) 78 | fake.comments(comments) 79 | fake.notifications(notifications) 80 | fake.groups(groups) 81 | fake.columns(columns) 82 | fake.messages(messages) 83 | 84 | print("\n[green]Success![/green]\n") 85 | 86 | @app.cli.command() 87 | @click.option("--drop/--no-drop", help="Drop database or not") 88 | def init_db(drop: bool = False) -> None: 89 | """Initialize database on a new machine.""" 90 | print( 91 | "\n[yellow]WARNING[/yellow]: the init-db command will initialize " 92 | + "your database, which may be destructive to your data." 93 | ) 94 | confirm("would you like to continue?", otherwise=exit) 95 | print("\n") 96 | if drop: 97 | print("dropping the database ...") 98 | db.drop_all(app=app) 99 | print("initializing the database ...\n") 100 | db.create_all(app=app) 101 | 102 | @app.cli.command() 103 | def deploy(): 104 | """Run deployment tasks""" 105 | 106 | print("\ndeployment tasks start.") 107 | from flask_migrate import upgrade, stamp 108 | 109 | try: 110 | # upgrade the database. 111 | print("upgrading from [yellow]flask-migrate[/yellow]") 112 | upgrade() 113 | print("[green]Success![/green]") 114 | except Exception: 115 | print("upgrade fails, initializing ...") 116 | db.create_all() 117 | print("stamping ...") 118 | stamp() 119 | print("[green]Success![/green]") 120 | print("\n") 121 | 122 | register_auth_group(app=app, db=db) 123 | -------------------------------------------------------------------------------- /flemi/fakes.py: -------------------------------------------------------------------------------- 1 | from random import randint 2 | 3 | from faker import Faker 4 | from flask import current_app 5 | from rich import print 6 | from rich.progress import track 7 | 8 | from .models import Column 9 | from .models import Comment 10 | from .models import db 11 | from .models import Group 12 | from .models import Message 13 | from .models import Notification 14 | from .models import Post 15 | from .models import User 16 | from .utils import lower_username 17 | 18 | fake = Faker() 19 | 20 | 21 | # !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! 22 | # WARNING: DO NOT USE FAKE DATA IN PRODUCTION ENVIRONMENT! 23 | # !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! 24 | 25 | 26 | def progress_bar(name: str, count: int) -> None: 27 | if not current_app.config: 28 | print(f"\ngenerating {name}: [magenta]{count}[/magenta]") 29 | 30 | 31 | def users(count: int = 10) -> None: 32 | """Generates fake users""" 33 | progress_bar("users", count) 34 | for _ in track(range(count), description="progress"): 35 | name = fake.name() 36 | username = lower_username(name) 37 | # Ensure the username is unique. 38 | if User.query.filter_by(username=username).first() is not None: 39 | continue 40 | user = User( 41 | username=username, 42 | name=name, 43 | email=fake.email(), 44 | confirmed=True, 45 | ) 46 | user.set_password("123456") 47 | db.session.add(user) 48 | db.session.commit() 49 | 50 | 51 | def posts(count: int = 10, private: bool = None) -> None: 52 | """Generates fake posts""" 53 | progress_bar("posts", count) 54 | for _ in track(range(count), description="progress"): 55 | post = Post( 56 | title=fake.word() + " " + fake.word(), 57 | content=fake.text(randint(100, 300)), 58 | timestamp=fake.date_time_this_year(), 59 | author=User.query.get(randint(1, User.query.count())), 60 | private=bool(randint(0, 1)) if private is None else private, 61 | ) 62 | db.session.add(post) 63 | db.session.commit() 64 | 65 | 66 | def comments(count: int = 10) -> None: 67 | """Generates fake comments for posts.""" 68 | progress_bar("comments", count) 69 | for _ in track(range(count), description="progress"): 70 | filt = Post.query.filter(~Post.private) 71 | comment = Comment( 72 | author=User.query.get(randint(1, User.query.count())), 73 | post=filt.all()[randint(1, filt.count() - 1)], 74 | body=fake.text(), 75 | ) 76 | db.session.add(comment) 77 | db.session.commit() 78 | 79 | 80 | def notifications(count: int, receiver: User = None) -> None: 81 | """Generates fake notifications""" 82 | progress_bar("notifications", count) 83 | for _ in track(range(count), description="progress"): 84 | if receiver is None: 85 | admin = User.query.filter_by(is_admin=True).first() 86 | receiver = admin 87 | notification = Notification( 88 | message=fake.sentence(), 89 | receiver=receiver, 90 | ) 91 | db.session.add(notification) 92 | db.session.commit() 93 | 94 | 95 | def groups(count: int) -> None: 96 | """Generates fake user groups""" 97 | progress_bar("groups", count) 98 | for _ in track(range(count), description="progress"): 99 | manager = User.query.get(randint(1, User.query.count())) 100 | group = Group(name=fake.sentence(), manager=manager) 101 | if manager: 102 | manager.join_group(group) 103 | db.session.add(group) 104 | db.session.commit() 105 | 106 | 107 | def columns(count: int) -> None: 108 | progress_bar("columns", count) 109 | for _ in track(range(count), description="progress"): 110 | posts = list({Post.query.get(randint(1, Post.query.count())) for _ in range(5)}) 111 | author = User.query.get(randint(1, User.query.count())) 112 | column = Column(name=fake.sentence(), author=author, posts=posts) 113 | db.session.add(column) 114 | db.session.commit() 115 | top = Column.query.get(randint(1, Column.query.count())) 116 | while top is None: 117 | top = Column.query.get(randint(1, Column.query.count())) 118 | db.session.commit() 119 | 120 | 121 | def messages(count: int) -> None: 122 | progress_bar("messages", count) 123 | for _ in track(range(count), description="progress"): 124 | group = Group.query.get(randint(1, Group.query.count())) 125 | message = Message(group=group, body=fake.sentence()) 126 | db.session.add(message) 127 | db.session.commit() 128 | -------------------------------------------------------------------------------- /flemi/commands/auth.py: -------------------------------------------------------------------------------- 1 | import re 2 | from getpass import getpass 3 | 4 | from flask import Flask 5 | from halo import Halo 6 | from rich import print 7 | 8 | from ..models import User 9 | from .cli import check 10 | from .cli import confirm 11 | from .cli import int_input 12 | 13 | 14 | def register_auth_group(app: Flask, db): 15 | @app.cli.group() 16 | def auth(): 17 | """ 18 | Authentication commands. 19 | """ 20 | pass 21 | 22 | @auth.command() 23 | def register(): 24 | """ 25 | Create a new account. 26 | """ 27 | print("\n[yellow]username[/yellow]: ", end="") 28 | username = input("") 29 | 30 | spinner_username = Halo(text="verifying username ...", spinner="dots") 31 | spinner_username.start() 32 | if User.query.filter_by(username=username).count() != 0: 33 | spinner_username.fail(text="verify username failed") 34 | print( 35 | f"[red]Fatal[/red]: username [green]{username}[/green] already exists.\n" 36 | ) 37 | exit(0) 38 | if not re.match(r"^[A-Za-z]([A-Za-z0-9_\-.]){5,11}$", username): 39 | spinner_username.fail(text="verify username failed") 40 | print( 41 | "[red]Fatal[/red]: match pattern fails: " 42 | "[cyan]^[A-Za-z]([A-Za-z0-9_-.]){5,11}$[/cyan] " 43 | f"(invalid username [green]{username}[/green])\n" 44 | ) 45 | exit(0) 46 | spinner_username.succeed(text="verify username success") 47 | 48 | print("[yellow]email[/yellow]: ", end="") 49 | email = input("") 50 | 51 | spinner_email = Halo(text="verifying email ...", spinner="dots") 52 | spinner_email.start() 53 | if User.query.filter_by(email=email).count() != 0: 54 | spinner_email.fail(text="verify email failed") 55 | print(f"[red]Fatal[/red]: email [green]{email}[/green] already exists.\n") 56 | exit(0) 57 | if not re.match(r"^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$", email): 58 | spinner_email.fail(text="verify email failed") 59 | print( 60 | f"[red]Fatal[/red]: match pattern fails (invalid email [green]{email}[/green])\n" 61 | ) 62 | exit(0) 63 | spinner_email.succeed(text="verify email success") 64 | 65 | print("[yellow]name[/yellow]: ", end="") 66 | name = input("") 67 | 68 | spinner_name = Halo(text="verifying name ...", spinner="dots") 69 | spinner_name.start() 70 | if not 1 < len(name) <= 64: 71 | spinner_name.fail(text="verify name failed") 72 | print( 73 | f"[red]Fatal[/red]: max length of content is 64 (currently {len(name)})\n" 74 | ) 75 | exit(0) 76 | spinner_name.succeed(text="verify name success") 77 | 78 | print(r"[yellow]about[/yellow] [green]\[Optional][/green]: ", end="") 79 | about = input("") 80 | 81 | print(r"[yellow]location[/yellow] [green]\[Optional][/green]: ", end="") 82 | location = input("") 83 | 84 | print("[yellow]password[/yellow]: ", end="") 85 | passwd = getpass("") 86 | 87 | print("[yellow]password again[/yellow]: ", end="") 88 | passwd_again = getpass("") 89 | 90 | spinner_pwd = Halo(text="verifying password ...", spinner="dots") 91 | spinner_pwd.start() 92 | if passwd != passwd_again: 93 | spinner_pwd.fail(text="verify password failed") 94 | print("[red]Fatal[/red]: password not match\n") 95 | exit(0) 96 | 97 | spinner_pwd.succeed(text="verify password success") 98 | 99 | u = User( 100 | username=username, email=email, name=name, about_me=about, location=location 101 | ) 102 | 103 | u.set_password(passwd) 104 | 105 | def register_exit(): 106 | db.session.add(u) 107 | db.session.commit() 108 | print("\ncreate user [green]success[/green]!\n") 109 | exit(0) 110 | 111 | print("\n[green]Nice![/green] the basic information is now ready.") 112 | 113 | confirm(prompt="want to make advanced configuration?", otherwise=register_exit) 114 | 115 | print("") 116 | if check(f"is [green]{username}[/green] an [yellow]administrator[/yellow]?"): 117 | u.is_admin = True 118 | 119 | if check(f"is [green]{username}[/green] [yellow]locked[/yellow]?"): 120 | u.locked = True 121 | 122 | u.coins = int_input( 123 | f"set initial [yellow]coins[/yellow] for [green]{username}[/green] (default to 3): ", 124 | default=3, 125 | ) 126 | 127 | u.experience = int_input( 128 | f"set initial [yellow]EXP[/yellow] for [green]{username}[/green] (default to 0): ", 129 | default=0, 130 | ) 131 | 132 | register_exit() 133 | -------------------------------------------------------------------------------- /flemi/shop_items.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | 3 | 4 | class Item: 5 | def __init__( 6 | self, 7 | name: str = "", 8 | category: str = "", 9 | exp: int = 0, 10 | gradient_deg: str = "45deg, ", 11 | expires: int = 0, 12 | color: str = "", 13 | **kwargs, 14 | ): 15 | self.name = name 16 | self.category = category 17 | self.exp = 0 if not exp else exp 18 | self.price = 0 19 | self.expires = timedelta(days=expires) 20 | if category == "": 21 | self.style = "" 22 | self.text_style = "color: inherit;" 23 | if category == "Classic": 24 | self.style = f"background-color: {color};" 25 | self.text_style = f"color: {color};" 26 | self.price = 5 * (expires / 30) - 2 * (expires / 30 - 1) - 0.01 27 | elif category == "Rare" or category == "Leveled": 28 | # gradient color 29 | gradient = f"linear-gradient({gradient_deg}{color})" 30 | self.style = f"background-image: {gradient};" 31 | self.text_style = f""" 32 | background: {gradient}; 33 | -webkit-background-clip: text; 34 | color: transparent; 35 | """ 36 | if category == "Rare": 37 | self.price = 9 * (expires / 30) - 2 * (expires / 30 - 1) - 0.01 38 | if "price" in kwargs.keys(): 39 | self.price = kwargs["price"] 40 | 41 | 42 | def items(id: int, mode="get") -> Item: 43 | item_list = ( 44 | Item(category=""), 45 | Item( 46 | name="Rose", 47 | expires=30, 48 | color="#DE2344", 49 | category="Classic", 50 | ), 51 | Item( 52 | name="Orange", 53 | expires=30, 54 | color="#FE9A2E", 55 | category="Classic", 56 | ), 57 | Item( 58 | name="Sun", 59 | expires=30, 60 | color="#EBBC34", 61 | category="Classic", 62 | ), 63 | Item( 64 | name="Mint", 65 | expires=30, 66 | color="#2EFE9A", 67 | category="Classic", 68 | ), 69 | Item( 70 | name="Copper 2+", 71 | expires=30, 72 | exp=100, 73 | color="#2E64FE", 74 | category="Classic", 75 | ), 76 | Item( 77 | name="Violet", 78 | expires=30, 79 | color="#7401DF", 80 | category="Classic", 81 | ), 82 | Item( 83 | name="Fire", 84 | expires=30, 85 | color="#8000FF, #FE2E64, #FE9A2E", 86 | category="Rare", 87 | ), 88 | Item( 89 | name="Frozen", 90 | expires=30, 91 | color="#5882FA, #81F7F3", 92 | category="Rare", 93 | ), 94 | Item( 95 | name="Shore", 96 | expires=30, 97 | color="#04B486, #04B486, #F2F5A9", 98 | category="Rare", 99 | ), 100 | Item( 101 | name="Aurora", 102 | expires=30, 103 | color="#08088A, #04B486", 104 | category="Rare", 105 | ), 106 | Item( 107 | name="Sweet", 108 | expires=30, 109 | color="#F5A9D0, #BE81F7", 110 | category="Rare", 111 | ), 112 | Item( 113 | name="Helium", 114 | expires=30, 115 | color="#FF8000, #FF8000, #F6E3CE, #FF8000, #FF8000", 116 | gradient_deg="", 117 | category="Rare", 118 | ), 119 | Item( 120 | name="Rainbow", 121 | expires=30, 122 | color="#FFFF00, #FF00FF, #00FFFF", 123 | category="Rare", 124 | ), 125 | Item( 126 | name="Seven", 127 | expires=99999, 128 | color="#00FFBF, #2E64FE", 129 | exp=1100, 130 | category="Leveled", 131 | ), 132 | Item( 133 | name="Crown", 134 | expires=99999, 135 | exp=2500, 136 | color="#4000FF, #DF01A5", 137 | category="Leveled", 138 | ), 139 | Item( 140 | name="Black Sea", 141 | expires=99999, 142 | exp=3100, 143 | color="#2CD8D5, #6B8DD6, #8E37D7", 144 | gradient_deg="-225deg, ", 145 | category="Leveled", 146 | price=16.66, 147 | ), 148 | Item( 149 | name="Sky Five", 150 | expires=99999, 151 | exp=5500, 152 | color="#D4FFEC 0%, #57F2CC 48%, #4596FB 100%", 153 | gradient_deg="-225deg, ", 154 | category="Leveled", 155 | price=16.66, 156 | ), 157 | Item( 158 | name="Amour", 159 | expires=99999, 160 | exp=1500, 161 | color="#f77062, #fe5196", 162 | gradient_deg="to top, ", 163 | category="Leveled", 164 | ), 165 | Item( 166 | name="Harmony", 167 | expires=30, 168 | exp=0, 169 | color="#3D4E81 0%, #5753C9 48%, #6E7FF3 100%", 170 | gradient_deg="-225deg, ", 171 | category="Rare", 172 | ), 173 | Item( 174 | name="Phoenix", 175 | expires=30, 176 | exp=0, 177 | color="#f83600, #f9d423", 178 | gradient_deg="to right, ", 179 | category="Rare", 180 | ), 181 | Item( 182 | name="Life", 183 | expires=60, 184 | exp=0, 185 | color="#43e97b, #38f9d7", 186 | gradient_deg="to right, ", 187 | category="Rare", 188 | ), 189 | Item( 190 | name="Beach", 191 | expires=60, 192 | exp=0, 193 | color="#4facfe, #00f2fe", 194 | gradient_deg="to right, ", 195 | category="Rare", 196 | ), 197 | ) 198 | return ( 199 | item_list[id] if mode == "get" else (len(item_list) if mode == "len" else None) 200 | ) 201 | -------------------------------------------------------------------------------- /flemi/api/post/views.py: -------------------------------------------------------------------------------- 1 | from unicodedata import name 2 | 3 | from apiflask import abort 4 | from apiflask import APIBlueprint 5 | from flask import request 6 | from flask.views import MethodView 7 | 8 | from ...extensions import db 9 | from ...models import Column 10 | from ...models import Comment 11 | from ...models import Post 12 | from ...utils import clean_html 13 | from ..auth.views import auth 14 | from ..decorators import can_edit 15 | from ..decorators import permission_required 16 | from .schemas import ColumnInSchema 17 | from .schemas import ColumnOutSchema 18 | from .schemas import CommentInSchema 19 | from .schemas import CommentOutSchema 20 | from .schemas import PostInSchema 21 | from .schemas import PostOutSchema 22 | from .schemas import PostsSchema 23 | 24 | post_bp = APIBlueprint("post", __name__) 25 | 26 | 27 | @post_bp.route("/posts") 28 | @post_bp.output(PostsSchema) 29 | def all_posts(): 30 | try: 31 | limit = request.args.get("limit", type=int) 32 | offset = request.args.get("offset", type=int) 33 | except ValueError: 34 | abort(400) 35 | return 36 | posts = Post.query.filter(~Post.private).all() 37 | posts_count = len(posts) 38 | start = offset 39 | end = min(start + limit, posts_count) 40 | prev = "" 41 | next = "" 42 | if start != 0: 43 | prev = f"/posts?limit={limit}&offset={offset-limit}" 44 | if end < posts_count - limit: 45 | next = f"/posts?limit={limit}&offset={offset+limit}" 46 | return {"posts": posts[start:end], "prev": prev, "next": next, "total": posts_count} 47 | 48 | 49 | @post_bp.route("/post/", endpoint="post") 50 | class PostAPI(MethodView): 51 | @post_bp.output(PostOutSchema) 52 | def get(self, post_id: int): 53 | post = Post.query.get_or_404(post_id) 54 | 55 | if post.private: 56 | user = auth.current_user 57 | if user and post in user.posts: 58 | return post 59 | abort(403, "the post is private") 60 | return post 61 | 62 | @permission_required 63 | @can_edit("post") 64 | @post_bp.input(PostInSchema(partial=True)) 65 | @post_bp.output(PostOutSchema) 66 | def put(self, post_id, data): 67 | post = Post.query.get(post_id) 68 | for attr, value in data.items(): 69 | if attr == "content": 70 | post.content = clean_html(value) 71 | elif attr == "column_ids": 72 | for column_id in data[attr]: 73 | column = Column.query.get_or_404(column_id) 74 | post.columns.append(column) 75 | else: 76 | post.__setattr__(attr, value) 77 | db.session.commit() 78 | return post 79 | 80 | @permission_required 81 | @can_edit("post") 82 | @post_bp.output({}, 204) 83 | def delete(self, post_id: int): 84 | post = Post.query.get(post_id) 85 | post.delete() 86 | 87 | 88 | @post_bp.post("/post") 89 | @permission_required 90 | @post_bp.input(PostInSchema) 91 | @post_bp.output({}, 201) 92 | def create_post(data): 93 | post = Post(author=auth.current_user) 94 | for attr, value in data.items(): 95 | if attr == "content": 96 | post.content = clean_html(value) 97 | elif attr == "column_ids": 98 | for column_id in data[attr]: 99 | column = Column.query.get_or_404(column_id) 100 | post.columns.append(column) 101 | else: 102 | post.__setattr__(attr, value) 103 | db.session.add(post) 104 | db.session.commit() 105 | return {}, 201 106 | 107 | 108 | @post_bp.route("/column/", endpoint="column") 109 | class ColumnAPI(MethodView): 110 | @post_bp.output(ColumnOutSchema) 111 | def get(self, column_id): 112 | column = Column.query.get_or_404(column_id) 113 | return column 114 | 115 | @permission_required 116 | @can_edit("column") 117 | @post_bp.input(ColumnInSchema(partial=True)) 118 | @post_bp.output(ColumnOutSchema) 119 | def put(self, column_id, data): 120 | column = Column.query.get(column_id) 121 | if data.get("name"): 122 | column.name = name 123 | if data.get("post_ids"): 124 | column.posts = [] 125 | for post_id in data["post_ids"]: 126 | post = Post.query.get(post_id) 127 | if post is None: 128 | abort(404, f"post {post_id} not found") 129 | column.posts.append(post) 130 | db.session.commit() 131 | return column 132 | 133 | @permission_required 134 | @can_edit("column") 135 | @post_bp.output({}, 204) 136 | def delete(self, column_id): 137 | column = Column.query.get(column_id) 138 | db.session.delete(column) 139 | db.session.commit() 140 | 141 | 142 | @post_bp.post("/column") 143 | @permission_required 144 | @post_bp.input(ColumnInSchema) 145 | @post_bp.output({}, 201) 146 | def create_column(data): 147 | column = Column(author=auth.current_user, name=data["name"]) 148 | for post_id in data["post_ids"]: 149 | post = Post.query.get(post_id) 150 | if post is None: 151 | abort(404, f"post {post_id} not found") 152 | column.posts.append(post) 153 | db.session.add(column) 154 | db.session.commit() 155 | return {}, 201 156 | 157 | 158 | @post_bp.route("/comment/", endpoint="comment") 159 | class CommentAPI(MethodView): 160 | @post_bp.output(CommentOutSchema) 161 | def get(self, comment_id: int): 162 | return Comment.query.get_or_404(comment_id) 163 | 164 | @permission_required 165 | @can_edit("comment") 166 | @post_bp.input(CommentInSchema(partial=True)) 167 | @post_bp.output(CommentOutSchema) 168 | def put(self, comment_id: int, data): 169 | comment = Comment.query.get(comment_id) 170 | for attr, value in data.items(): 171 | if attr == "reply_id": 172 | comment.replied = Comment.query.get_or_404(value) 173 | elif attr == "post_id": 174 | post = Post.query.get_or_404(value) 175 | if post.private: 176 | abort(400, "the post is private") 177 | comment.post = post 178 | elif attr == "body": 179 | comment.body = clean_html(value) 180 | db.session.commit() 181 | return comment 182 | 183 | @permission_required 184 | @can_edit("comment") 185 | @post_bp.output({}, 204) 186 | def delete(self, comment_id: int): 187 | comment = Comment.query.get(comment_id) 188 | comment.delete() 189 | 190 | 191 | @post_bp.post("/comment") 192 | @permission_required 193 | @post_bp.input(CommentInSchema) 194 | @post_bp.output({}, 201) 195 | def create_comment(data): 196 | comment = Comment( 197 | author=auth.current_user, 198 | body=clean_html(data["body"]), 199 | ) 200 | post = Post.query.get_or_404(data["post_id"]) 201 | if post.private: 202 | abort(400, "the post is private") 203 | comment.post = post 204 | if data.get("reply_id"): 205 | comment.replied = Comment.query.get_or_404(data["reply_id"]) 206 | if comment.replied not in comment.post.comments: 207 | abort( 208 | 400, 209 | "the comment you reply does not belong to the post", 210 | ) 211 | db.session.add(comment) 212 | db.session.commit() 213 | return {}, 201 214 | -------------------------------------------------------------------------------- /flemi/models.py: -------------------------------------------------------------------------------- 1 | """ 2 | MIT License 3 | Copyright (c) 2020 Andy Zhou 4 | """ 5 | import hashlib 6 | import os 7 | from datetime import datetime 8 | from datetime import timedelta 9 | from time import time 10 | 11 | from authlib.jose import jwt 12 | from authlib.jose.errors import JoseError 13 | from flask import current_app 14 | from flask import url_for 15 | from werkzeug.security import check_password_hash 16 | from werkzeug.security import generate_password_hash 17 | 18 | from .extensions import db 19 | from .shop_items import items 20 | 21 | 22 | group_user_table = db.Table( 23 | "group_user", 24 | db.Column("user_id", db.Integer, db.ForeignKey("user.id")), 25 | db.Column("group_id", db.Integer, db.ForeignKey("group.id")), 26 | extend_existing=True, 27 | ) 28 | 29 | column_post_table = db.Table( 30 | "column_post", 31 | db.Column("post_id", db.Integer, db.ForeignKey("post.id")), 32 | db.Column("column_id", db.Integer, db.ForeignKey("column.id")), 33 | extend_existing=True, 34 | ) 35 | 36 | coin_table = db.Table( 37 | "coin_table", 38 | db.Column("owner_id", db.Integer, db.ForeignKey("user.id")), 39 | db.Column("post_id", db.Integer, db.ForeignKey("post.id")), 40 | extend_existing=True, 41 | ) 42 | 43 | Collect = db.Table( 44 | "collect", 45 | db.Column("post_id", db.Integer, db.ForeignKey("post.id")), 46 | db.Column("user_id", db.Integer, db.ForeignKey("user.id")), 47 | ) 48 | 49 | 50 | class Belong(db.Model): 51 | """ 52 | A model describing the relationship of user and shop items. 53 | """ 54 | 55 | id = db.Column(db.Integer, primary_key=True) 56 | 57 | owner_id = db.Column( 58 | db.Integer(), 59 | db.ForeignKey("user.id"), 60 | ) 61 | goods_id = db.Column( 62 | db.Integer(), 63 | ) 64 | expires = db.Column(db.DateTime) 65 | owner = db.relationship("User", back_populates="belongings") 66 | 67 | def __str__(self): 68 | return f" User {self.owner_id}>" 69 | 70 | def load_expiration_delta(self): 71 | delta = self.expires - datetime.utcnow() 72 | return delta 73 | 74 | 75 | class Post(db.Model): 76 | """ 77 | A model for posts. 78 | """ 79 | 80 | # Post id 81 | id = db.Column(db.Integer, primary_key=True) 82 | 83 | # Initial information 84 | title = db.Column(db.String(128), index=True) 85 | author_id = db.Column(db.Integer, db.ForeignKey("user.id")) 86 | author = db.relationship("User", back_populates="posts") 87 | content = db.Column(db.UnicodeText) 88 | timestamp = db.Column(db.DateTime, default=datetime.utcnow, index=True) 89 | private = db.Column(db.Boolean, default=False) 90 | 91 | # Others 92 | comments = db.relationship("Comment", back_populates="post") 93 | columns = db.relationship( 94 | "Column", secondary=column_post_table, back_populates="posts" 95 | ) 96 | coins = db.Column(db.Integer, default=0) 97 | coiners = db.relationship( 98 | "User", secondary=coin_table, back_populates="coined_posts" 99 | ) 100 | 101 | def __init__(self, **kwargs): 102 | super().__init__(**kwargs) 103 | 104 | def __repr__(self) -> str: 105 | return f"" 106 | 107 | @property 108 | def picked(self): 109 | """ 110 | Decide whether a post is picked. 111 | """ 112 | # key HOT_POST_COIN must be included in config. 113 | return self.coins >= current_app.config["HOT_POST_COIN"] 114 | 115 | def delete(self): 116 | """ 117 | Delete a post. 118 | """ 119 | db.session.delete(self) 120 | db.session.commit() 121 | 122 | def add_coin(self, coin_num: int, current_user): 123 | """ 124 | Give a certain number of coins to a post. 125 | """ 126 | if coin_num not in ( 127 | 1, 128 | 2, 129 | ): # Used when a user put an invalid number of coins. 130 | return "Invalid coin!" 131 | if self in current_user.coined_posts: # A post cannot be recoined. 132 | return "Invalid coin!" 133 | if self.author == current_user: # Used for flemio, so that flemio CLI users 134 | return "You can't coin yourself." # cannot give coins to their own posts. 135 | 136 | amount = coin_num 137 | if current_user.coins < amount: # A user must have enough coin to give. 138 | return "Not enough coins." 139 | 140 | current_user.coined_posts.append(self) 141 | current_user.coins -= amount 142 | current_user.experience += amount * 10 143 | self.coins += amount 144 | 145 | if self.author: # The author will be given 1/4 146 | self.author.coins += amount / 4 # of the number of coins given. 147 | self.author.experience += amount * 10 148 | 149 | db.session.commit() 150 | 151 | 152 | class Column(db.Model): 153 | """ 154 | A column of posts. 155 | """ 156 | 157 | # Initial information 158 | id = db.Column(db.Integer, primary_key=True) 159 | 160 | name = db.Column(db.String(128), unique=True) 161 | posts = db.relationship( 162 | "Post", secondary=column_post_table, back_populates="columns" 163 | ) 164 | author = db.relationship("User", back_populates="columns") 165 | author_id = db.Column(db.Integer, db.ForeignKey("user.id")) 166 | timestamp = db.Column(db.DateTime, default=datetime.utcnow) 167 | 168 | def coins(self): 169 | """ 170 | The total coins of the column. 171 | """ 172 | return sum(post.coins for post in self.posts) 173 | 174 | @property 175 | def topped(self): 176 | """ 177 | Working like picking posts. 178 | """ 179 | return self.coins() >= current_app.config["HOT_COLUMN_COIN"] 180 | 181 | def delete(self): 182 | """ 183 | Delete a column. (Posts inside will be okay) 184 | """ 185 | db.session.delete(self) 186 | db.session.commit() 187 | 188 | 189 | class Comment(db.Model): 190 | """ 191 | A Comment Model. 192 | """ 193 | 194 | # Initial information 195 | id = db.Column(db.Integer, primary_key=True) 196 | 197 | body = db.Column(db.UnicodeText) 198 | timestamp = db.Column(db.DateTime, default=datetime.utcnow, index=True) 199 | author_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) 200 | post_id = db.Column(db.Integer, db.ForeignKey("post.id")) 201 | post = db.relationship("Post", back_populates="comments") 202 | author = db.relationship("User", back_populates="comments") 203 | flag = db.Column(db.Integer, default=0) 204 | 205 | # comment reply 206 | replied_id = db.Column(db.Integer, db.ForeignKey("comment.id")) 207 | replies = db.relationship("Comment", back_populates="replied", cascade="all") 208 | replied = db.relationship("Comment", back_populates="replies", remote_side=[id]) 209 | 210 | def delete(self): 211 | """ 212 | Delete a comment. 213 | """ 214 | db.session.delete(self) 215 | db.session.commit() 216 | 217 | 218 | class Notification(db.Model): 219 | """ 220 | The model of Notification. 221 | """ 222 | 223 | # Initial information 224 | id = db.Column(db.Integer, primary_key=True) 225 | 226 | message = db.Column(db.Text) 227 | timestamp = db.Column(db.DateTime, default=datetime.utcnow, index=True) 228 | receiver_id = db.Column(db.Integer, db.ForeignKey("user.id")) 229 | receiver = db.relationship("User", back_populates="notifications") 230 | 231 | def push(self): 232 | """ 233 | Push a notification to the user. 234 | """ 235 | db.session.add(self) 236 | db.session.commit() 237 | 238 | def delete(self): 239 | """ 240 | Delete a notification. 241 | """ 242 | db.session.delete(self) 243 | db.session.commit() 244 | 245 | 246 | class Image(db.Model): 247 | """ 248 | A Image file uploaded to server. 249 | """ 250 | 251 | # Initial information 252 | id = db.Column(db.Integer, primary_key=True) 253 | 254 | filename = db.Column(db.String(512), unique=True) 255 | author_id = db.Column(db.Integer, db.ForeignKey("user.id")) 256 | author = db.relationship("User", back_populates="images") 257 | private = db.Column(db.Boolean, default=False) 258 | timestamp = db.Column(db.DateTime, default=datetime.utcnow) 259 | 260 | def url(self) -> str: 261 | return url_for("image.uploaded_files", filename=self.filename, _external=True) 262 | 263 | def path(self) -> str: 264 | return os.path.join(current_app.config["UPLOAD_DIRECTORY"], self.filename) 265 | 266 | def toggle_visibility(self) -> None: 267 | """ 268 | Change the visibility of the image uploaded. 269 | """ 270 | self.private = not self.private 271 | db.session.commit() 272 | 273 | def delete(self) -> None: 274 | """ 275 | Delete an image (from the server). 276 | """ 277 | try: 278 | os.remove(self.path()) 279 | except FileNotFoundError: 280 | pass 281 | db.session.delete(self) 282 | db.session.commit() 283 | 284 | 285 | class Group(db.Model): 286 | """ 287 | A Group model used for chat. 288 | """ 289 | 290 | # Initial information 291 | id = db.Column(db.Integer, primary_key=True) 292 | 293 | name = db.Column(db.String(128), unique=True) 294 | members = db.relationship( 295 | "User", secondary=group_user_table, back_populates="groups" 296 | ) 297 | manager = db.relationship("User", back_populates="managed_groups") 298 | manager_id = db.Column(db.Integer, db.ForeignKey("user.id")) 299 | messages = db.relationship("Message", back_populates="group") 300 | private = db.Column(db.Boolean, default=False) 301 | 302 | def delete(self): 303 | """ 304 | Delete a group. 305 | """ 306 | db.session.delete(self) 307 | db.session.commit() 308 | 309 | 310 | class Message(db.Model): 311 | """ 312 | Message Model in the group. 313 | """ 314 | 315 | id = db.Column(db.Integer, primary_key=True) 316 | 317 | body = db.Column(db.Text) 318 | group_id = db.Column(db.Integer, db.ForeignKey("group.id")) 319 | group = db.relationship("Group", back_populates="messages") 320 | author_id = db.Column(db.Integer, db.ForeignKey("user.id")) 321 | author = db.relationship("User", back_populates="sent_messages") 322 | timestamp = db.Column(db.DateTime, default=datetime.utcnow, index=True) 323 | 324 | 325 | class User(db.Model): 326 | """ 327 | User model. 328 | """ 329 | 330 | id = db.Column(db.Integer, primary_key=True) 331 | 332 | email = db.Column(db.String(256)) 333 | username = db.Column(db.String(32), unique=True, index=True) 334 | password_hash = db.Column(db.String(128)) 335 | confirmed = db.Column(db.Boolean, default=False) 336 | posts = db.relationship("Post", back_populates="author") 337 | avatar_hash = db.Column(db.String(32)) 338 | 339 | locked = db.Column(db.Boolean, default=False) 340 | is_admin = db.Column(db.Boolean, default=False) 341 | 342 | name = db.Column(db.String(64)) 343 | location = db.Column(db.String(64)) 344 | about_me = db.Column(db.Text) 345 | member_since = db.Column(db.DateTime(), default=datetime.utcnow()) 346 | last_seen = db.Column(db.DateTime(), default=datetime.utcnow()) 347 | 348 | collections = db.relationship( 349 | "Post", secondary=Collect, backref=db.backref("collectors") 350 | ) 351 | 352 | locale = db.Column(db.String(16)) 353 | 354 | columns = db.relationship("Column", back_populates="author") 355 | 356 | comments = db.relationship("Comment", back_populates="author", cascade="all") 357 | notifications = db.relationship( 358 | "Notification", back_populates="receiver", cascade="all" 359 | ) 360 | images = db.relationship("Image", back_populates="author", cascade="all") 361 | groups = db.relationship( 362 | "Group", secondary=group_user_table, back_populates="members" 363 | ) 364 | managed_groups = db.relationship("Group", back_populates="manager") 365 | custom_avatar_url = db.Column(db.String(128), default="") 366 | sent_messages = db.relationship("Message", back_populates="author") 367 | coins = db.Column(db.Float, default=3) 368 | experience = db.Column(db.Integer, default=0) 369 | coined_posts = db.relationship( 370 | "Post", secondary=coin_table, back_populates="coiners" 371 | ) 372 | 373 | belongings = db.relationship("Belong", back_populates="owner") 374 | 375 | avatar_style_id = db.Column(db.Integer(), default=0) 376 | 377 | clicks = db.Column(db.Integer(), default=0) 378 | clicks_today = db.Column(db.Integer(), default=0) 379 | 380 | default_status = db.Column(db.String(64), default="online") 381 | # online, idle, focus, offline 382 | 383 | remote_addr = db.Column(db.String(), default="") 384 | 385 | password_update = db.Column(db.Float(), default=0) 386 | 387 | def __init__(self, **kwargs): 388 | super().__init__(**kwargs) 389 | if self.email is not None and self.avatar_hash is None: 390 | self.avatar_hash = self.gravatar_hash() 391 | 392 | def __repr__(self): 393 | return f"" 394 | 395 | def set_password(self, password): 396 | self.password_hash = generate_password_hash(password) 397 | 398 | def is_administrator(self): 399 | return self.is_admin 400 | 401 | def verify_password(self, password) -> bool: 402 | return check_password_hash(self.password_hash, password) 403 | 404 | def can(self, perm) -> bool: 405 | return not self.locked 406 | 407 | def gravatar_hash(self): 408 | return hashlib.md5(self.email.lower().encode("utf-8")).hexdigest() 409 | 410 | def avatar_url(self, size=30): 411 | if self.custom_avatar_url: 412 | return self.custom_avatar_url 413 | url = "https://rice0208.pythonanywhere.com/silicon/v1" # use silicon generator 414 | hash = self.avatar_hash or self.gravatar_hash() 415 | return f"{url}/{hash}?s={size}" 416 | 417 | def ping(self, force_time=None): 418 | now = datetime.utcnow() if force_time is None else force_time 419 | last_seen_day = datetime( 420 | self.last_seen.year, self.last_seen.month, self.last_seen.day 421 | ) 422 | self.coins = self.coins or 3.0 # maybe this account is processed 423 | if now - last_seen_day >= timedelta(days=1): 424 | self.coins += 1 425 | self.clicks_today = 0 426 | self.last_seen = now 427 | db.session.commit() 428 | 429 | def auth_token(self): 430 | header = {"alg": "HS256"} 431 | payload = {"uid": self.id, "time": time()} 432 | return jwt.encode(header, payload, current_app.config["SECRET_KEY"]).decode() 433 | 434 | def gen_email_verify_token(self): 435 | header = {"alg": "HS256"} 436 | payload = {"uid": self.id, "email": self.email, "time": time()} 437 | token = jwt.encode(header, payload, current_app.config["SECRET_KEY"]).decode() 438 | return token 439 | 440 | @staticmethod 441 | def verify_email_token(self, token: str): 442 | try: 443 | data = jwt.decode(token.encode("ascii"), current_app.config["SECRET_KEY"]) 444 | if data.get("time") + 900 > time(): 445 | self.confirmed = True 446 | return True 447 | raise JoseError("Token expired") 448 | except JoseError: 449 | self.confirmed = False 450 | return False 451 | 452 | def delete(self): 453 | db.session.delete(self) 454 | db.session.commit() 455 | 456 | def collect(self, post): 457 | if not self.is_collecting(post): 458 | self.collections.append(post) 459 | db.session.commit() 460 | 461 | def uncollect(self, post): 462 | if post in self.collections: 463 | self.collections.remove(post) 464 | db.session.commit() 465 | 466 | def is_collecting(self, post): 467 | return post in self.collections 468 | 469 | def join_group(self, group) -> None: 470 | self.groups.append(group) 471 | db.session.add(self) 472 | db.session.add(group) 473 | db.session.commit() 474 | 475 | def in_group(self, group) -> bool: 476 | return self in group.members 477 | 478 | def lock(self): 479 | self.locked = True 480 | db.session.commit() 481 | 482 | def unlock(self): 483 | self.locked = False 484 | db.session.commit() 485 | 486 | def level(self) -> int: 487 | if self.experience < 100: 488 | return 1 489 | elif self.experience < 200: 490 | return 2 491 | elif self.experience < 350: 492 | return 3 493 | elif self.experience < 550: 494 | return 4 495 | elif self.experience < 800: 496 | return 5 497 | elif self.experience < 1100: 498 | return 6 499 | elif self.experience < 1500: 500 | return 7 501 | elif self.experience < 2500: 502 | return 8 503 | else: 504 | lv = 9 505 | while (lv - 8) * (lv - 7) * 100 + 2500 <= self.experience: 506 | lv += 1 507 | return lv 508 | 509 | def level_badge_link(self) -> str: 510 | """ 511 | The link of a level badge 512 | """ 513 | lv = self.level() 514 | prefix = "https://img.shields.io/badge/Lv" + str(min(lv, 9)) + "%20" 515 | if lv <= 8: 516 | color = "" 517 | if lv == 1: 518 | color = "eee" 519 | elif lv == 2: 520 | color = "ff9" 521 | elif lv == 3: 522 | color = "afa" 523 | elif lv == 4: 524 | color = "5d5" 525 | elif lv == 5: 526 | color = "0dd" 527 | elif lv == 6: 528 | color = "00f" 529 | elif lv == 7: 530 | color = "da3" 531 | elif lv == 8: 532 | color = "f00" 533 | return prefix + "-" + color 534 | else: 535 | plus = lv - 9 536 | return prefix + "%2B" + str(plus) + "-808" 537 | 538 | def load_belongings(self): 539 | belongings = [ 540 | item for item in self.belongings if item.expires > datetime.utcnow() 541 | ] 542 | return belongings 543 | 544 | def load_belongings_id(self): 545 | return [item.goods_id for item in self.load_belongings()] 546 | 547 | def load_avatar_style(self, size=36): 548 | if self.avatar_style_id is None: 549 | self.avatar_style_id = 0 550 | db.session.commit() 551 | style = items(self.avatar_style_id).style 552 | if self.avatar_style_id in [item.goods_id for item in self.load_belongings()]: 553 | return style.format(size / 160) 554 | return "" 555 | 556 | def load_username_style(self): 557 | if self.avatar_style_id is None: 558 | self.avatar_style_id = 0 559 | db.session.commit() 560 | style = items(self.avatar_style_id).text_style 561 | if self.avatar_style_id in [item.goods_id for item in self.load_belongings()]: 562 | return style 563 | return "" 564 | 565 | def word_count(self): 566 | """ 567 | The length of content. 568 | """ 569 | return sum(len(post.content) for post in self.posts) 570 | 571 | def get_alpha(self): 572 | """ 573 | Get the Alpha Index describing how a user is in the site. 574 | """ 575 | 576 | def _get_recent_posts(user): 577 | return [ 578 | post 579 | for post in user.posts 580 | if (datetime.utcnow() - post.timestamp < timedelta(days=21)) 581 | ] 582 | 583 | def _get_recent_comments(user): 584 | return [ 585 | comment 586 | for comment in user.comments 587 | if (datetime.utcnow() - comment.timestamp < timedelta(days=21)) 588 | ] 589 | 590 | v5 = ( 591 | (True if self.name else False) 592 | + (True if self.location else False) 593 | + (len(self.about_me or "hello world") >= 20) 594 | ) / 3 595 | 596 | def _get_total_post_content(user): 597 | return sum(len(p.content) for p in _get_recent_posts(user)) 598 | 599 | def _get_total_comment(user): 600 | return sum(len(c.body) for c in _get_recent_comments(user)) 601 | 602 | def _get_total_coins_in_posts(user): 603 | return sum(p.coins for p in _get_recent_posts(user)) 604 | 605 | def _get_recent_coined_posts(user): 606 | return [ 607 | post 608 | for post in user.coined_posts 609 | if (datetime.utcnow() - post.timestamp < timedelta(40)) 610 | ] 611 | 612 | def _get_total_coins_given(user): 613 | return sum(p.coins for p in _get_recent_coined_posts(user)) 614 | 615 | pc, cc, tc, tc_ = ( 616 | _get_total_post_content(self), 617 | _get_total_comment(self), 618 | _get_total_coins_in_posts(self), 619 | _get_total_coins_given(self), 620 | ) 621 | 622 | def _get_preportion(self_count, func): 623 | max_count = max(func(user) for user in User.query.all()) 624 | return 0 if max_count == 0 else self_count / max_count 625 | 626 | v1 = _get_preportion(pc, _get_total_post_content) 627 | v2 = _get_preportion(cc, _get_total_comment) 628 | v3 = _get_preportion(tc, _get_total_coins_in_posts) 629 | v4 = _get_preportion(tc_, _get_total_coins_given) 630 | 631 | pi = 3.141592653589793 632 | s2 = 1.4142135623730951 633 | return ( 634 | (v1 * pi / 2 + v2 * pi / 2 + v3 * pi / 2 + v4 * pi / 4 + v5 * pi / 4) 635 | * 100 636 | / (4 * s2) 637 | ).__round__(2) 638 | 639 | def ping_update_ai(self): 640 | now = datetime.utcnow() 641 | sl = datetime.utcfromtimestamp(self.last_update) or datetime( 642 | 2000, 1, 1, 0, 0, 0, 0 643 | ) 644 | if now >= datetime( 645 | year=sl.year, month=sl.month, day=sl.day, hour=(sl.hour // 12 + 1) * 12 646 | ): 647 | self.alpha_index = self.get_alpha() 648 | self.last_update = now 649 | 650 | def post_count(self): 651 | return len(self.posts) 652 | 653 | def post_coins(self): 654 | return sum(post.coins for post in self.posts) 655 | 656 | def post_collects(self): 657 | return sum(len(post.collectors) for post in self.posts) 658 | --------------------------------------------------------------------------------