├── .github └── workflows │ └── cd-build-push-deploy.yaml ├── .gitignore ├── README.md ├── app ├── .env.example ├── __init__.py ├── auth │ ├── __init__.py │ ├── decorators.py │ ├── forms.py │ ├── models.py │ ├── routes.py │ └── utils.py ├── config.py ├── errors │ ├── __init__.py │ └── handlers.py ├── extensions.py ├── main │ ├── __init__.py │ ├── models.py │ └── routes.py ├── queue │ ├── __init__.py │ ├── forms.py │ ├── models.py │ └── routes.py ├── static │ ├── favicon.ico │ ├── js │ │ ├── base.js │ │ └── load_theme.js │ └── stylesheets │ │ └── style.css ├── templates │ ├── auth │ │ ├── email │ │ │ ├── confirm_email.html │ │ │ └── reset_password.html │ │ ├── inactive.html │ │ ├── login.html │ │ ├── register.html │ │ ├── reset_password.html │ │ └── reset_password_request.html │ ├── base.html │ ├── errors │ │ ├── 404.html │ │ └── 500.html │ ├── main │ │ └── index.html │ └── queue │ │ ├── create_queue.html │ │ ├── manage_queue.html │ │ ├── my_queues.html │ │ └── queue.html ├── translations │ └── ru │ │ └── LC_MESSAGES │ │ ├── messages.mo │ │ └── messages.po └── utils.py ├── babel.cfg ├── bots └── taskbot │ └── taskbot.py ├── docker-compose.yaml ├── docker └── backend │ ├── Dockerfile │ └── run.sh ├── messages.pot ├── migrations ├── README ├── alembic.ini ├── env.py ├── script.py.mako └── versions │ └── c03051ba358c_.py ├── passenger_wsgi.py ├── queue_app.py ├── readme_resources ├── creating.png ├── joining.png ├── managing_1.png ├── managing_2.png ├── managing_3.png ├── options.png ├── queue.png └── sharing.png ├── requirements.txt └── tests.py /.github/workflows/cd-build-push-deploy.yaml: -------------------------------------------------------------------------------- 1 | name: Publish Docker image 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | 7 | jobs: 8 | push_to_registry: 9 | name: Push Docker image to Docker Hub 10 | runs-on: ubuntu-latest 11 | permissions: 12 | packages: write 13 | contents: read 14 | attestations: write 15 | id-token: write 16 | steps: 17 | - name: Check out the repo 18 | uses: actions/checkout@v4 19 | 20 | - name: Set short git commit SHA 21 | id: vars 22 | run: | 23 | calculatedSha=$(git rev-parse --short ${{ github.sha }}) 24 | echo "COMMIT_SHORT_SHA=$calculatedSha" >> $GITHUB_ENV 25 | 26 | - name: Log in to Docker Hub 27 | uses: docker/login-action@v2 28 | with: 29 | username: ${{ secrets.DOCKER_USERNAME }} 30 | password: ${{ secrets.DOCKER_PASSWORD }} 31 | 32 | - name: Build and push Docker image 33 | id: push 34 | uses: docker/build-push-action@v5 35 | with: 36 | context: . 37 | file: ./docker/backend/Dockerfile 38 | push: true 39 | tags: | 40 | vaniog/queuehere:latest 41 | vaniog/queuehere:${{ env.COMMIT_SHORT_SHA }} 42 | 43 | - name: Trigger watchtower to update container(s) 44 | shell: bash 45 | run: | 46 | curl -H "Authorization: Bearer ${{ secrets.WATCHTOWER_HTTP_API_TOKEN }}" ${{ secrets.WATCHTOWER_UPDATE_ENDPOINT }} 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | __pycache__ 3 | logs 4 | app.db 5 | venv 6 | cache 7 | gunicorn.log 8 | app/.env 9 | scripts/ 10 | sessions 11 | .htaccess 12 | data -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # QueueHere 2 | 3 | Website for creating, managing and sharing queues. 4 | 5 | Includes english and russian languages. 6 | 7 | http://queue-here.ru 8 | 9 | - [Demonstration](#demonstration) 10 | - [Details](#details) 11 | 12 | # Demonstration 13 | 14 | ## Options 15 | 16 | ![options](readme_resources/options.png) 17 | 18 | ## Queue 19 | 20 | ![queue](readme_resources/queue.png) 21 | 22 | ## Share 23 | 24 | ![sharing](readme_resources/sharing.png) 25 | 26 | ## Join 27 | 28 | ![joining](readme_resources/joining.png) 29 | 30 | ## Create 31 | 32 | ![creating](readme_resources/creating.png) 33 | 34 | ## Manage 35 | 36 | ![managing](readme_resources/managing_1.png) 37 | 38 | ![managing 2](readme_resources/managing_2.png) 39 | 40 | ![managing 3](readme_resources/managing_3.png) 41 | 42 | # Details 43 | 44 | Backend with python web framework [flask](https://flask.palletsprojects.com/en/2.2.x/). 45 | Frontend with [bootstrap](https://getbootstrap.com/). \ 46 | I created it for queues for labs passing. It can be used in similar cases. 47 | 48 | To run on your pc. 49 | 50 | # cloning 51 | git clone https://github.com/Vaniog/QueueHere 52 | cd QueueHere 53 | 54 | # creating virtual enviroment 55 | python3 -m venv 56 | 57 | # activating the enviroment 58 | 59 | # for windows 60 | # venv\Scripts\activate.bat 61 | 62 | # for linux 63 | # source venv/bin/activate 64 | 65 | # flask env variable 66 | export FLASK_APP=queue_app 67 | 68 | # running 69 | flask run -h 0.0.0.0 70 | 71 | You can create app/.env file and load there some variables. 72 | Look for them in app/config.py -------------------------------------------------------------------------------- /app/.env.example: -------------------------------------------------------------------------------- 1 | DATABASE_URL= 2 | EMAIL_USER= 3 | EMAIL_PASSWORD= 4 | FLASK_RUN_EXTRA_FILES= 5 | MARIADB_ROOT_PASSWORD= 6 | MARIADB_DATABASE= -------------------------------------------------------------------------------- /app/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Flask 2 | from app.config import Config 3 | from app.extensions import db, login, migrate, bootstrap, mail, moment, babel, qrcode, csrf 4 | from app.utils import get_locale 5 | from app.main.models import Stats, StatsEnum 6 | import logging 7 | from logging.handlers import RotatingFileHandler 8 | import os 9 | 10 | from bots.taskbot.taskbot import TaskBotThread 11 | from flask_babel import lazy_gettext as _l 12 | 13 | 14 | def create_app(config_obj=Config()): 15 | app = Flask(__name__) 16 | app.config.from_object(config_obj) 17 | db.init_app(app) 18 | login.init_app(app) 19 | migrate.init_app(app, db, render_as_batch=True) 20 | bootstrap.init_app(app) 21 | mail.init_app(app) 22 | moment.init_app(app) 23 | babel.init_app(app, locale_selector=get_locale) 24 | qrcode.init_app(app) 25 | csrf.init_app(app) 26 | 27 | app.jinja_env.globals.update(get_locale=get_locale) 28 | 29 | from app.main import bp as main_bp 30 | app.register_blueprint(main_bp) 31 | 32 | from app.errors import bp as err_bp 33 | app.register_blueprint(err_bp) 34 | 35 | from app.auth import bp as auth_bp 36 | app.register_blueprint(auth_bp) 37 | 38 | from app.queue import bp as queue_bp 39 | app.register_blueprint(queue_bp) 40 | 41 | login.login_view = 'auth.login' 42 | login.login_message = _l('Please login to access this page') 43 | login.session_protection = "strong" 44 | 45 | from app.auth.models import User 46 | 47 | @login.user_loader 48 | def load_user(user_id): 49 | return User.query.get(int(user_id)) 50 | 51 | if not app.debug: 52 | if not os.path.exists('logs'): 53 | os.mkdir('logs') 54 | file_handler = RotatingFileHandler('logs/microblog.txt', maxBytes=10240, backupCount=10) 55 | 56 | file_handler.setFormatter(logging.Formatter( 57 | '%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]') 58 | ) 59 | file_handler.setLevel(logging.INFO) 60 | app.logger.addHandler(file_handler) 61 | 62 | app.logger.setLevel(logging.INFO) 63 | app.logger.info('Microblog startup') 64 | 65 | bot_thread = TaskBotThread(app) 66 | bot_thread.start() 67 | 68 | @app.context_processor 69 | def inject_stage_and_region(): 70 | return dict(Stats=Stats, 71 | StatsEnum=StatsEnum) 72 | 73 | return app 74 | 75 | 76 | from app.main import routes 77 | from app.errors import handlers 78 | -------------------------------------------------------------------------------- /app/auth/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | bp = Blueprint('auth', __name__) 4 | 5 | from app.auth import routes 6 | -------------------------------------------------------------------------------- /app/auth/decorators.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | from flask import flash, redirect, url_for, abort 3 | from flask_login import current_user 4 | from flask_babel import _ 5 | 6 | 7 | def check_is_confirmed(func): 8 | @wraps(func) 9 | def decorated_function(*args, **kwargs): 10 | if current_user.is_anonymous or not current_user.is_confirmed: 11 | flash(_("Please confirm your account!"), "warning") 12 | return redirect(url_for("auth.inactive")) 13 | return func(*args, **kwargs) 14 | 15 | return decorated_function 16 | 17 | 18 | def check_is_admin(func): 19 | @wraps(func) 20 | def decorated_function(*args, **kwargs): 21 | if not current_user.is_authenticated or not current_user.is_admin: 22 | abort(404) 23 | return func(*args, **kwargs) 24 | 25 | return decorated_function 26 | -------------------------------------------------------------------------------- /app/auth/forms.py: -------------------------------------------------------------------------------- 1 | from flask_wtf import FlaskForm 2 | from wtforms import StringField, PasswordField, BooleanField, SubmitField, ValidationError 3 | from wtforms.validators import DataRequired, EqualTo, Email, Length 4 | from app.auth.models import User 5 | from flask_babel import lazy_gettext as _l 6 | 7 | 8 | class LoginForm(FlaskForm): 9 | username = StringField(_l('Username'), validators=[DataRequired(), Length(max=64)]) 10 | password = PasswordField(_l('Password'), validators=[DataRequired()]) 11 | submit = SubmitField(_l('Sign In')) 12 | 13 | 14 | class RegistrationForm(FlaskForm): 15 | username = StringField(_l('Username'), validators=[DataRequired(), Length(max=64)]) 16 | email = StringField(_l('Email'), validators=[DataRequired(), Email(), Length(max=120)]) 17 | password = PasswordField(_l('Password'), validators=[DataRequired()]) 18 | password2 = PasswordField( 19 | _l('Repeat Password'), validators=[DataRequired(), EqualTo('password')]) 20 | submit = SubmitField(_l('Register')) 21 | 22 | def validate_email(self, email): 23 | user = User.query.filter_by(email=email.data).first() 24 | if user is not None: 25 | raise ValidationError(_l('This address is already in use.')) 26 | 27 | def validate_username(self, username): 28 | user = User.query.filter_by(username=username.data).first() 29 | if user is not None: 30 | raise ValidationError(_l('This username is already in use.')) 31 | 32 | 33 | class ResetPasswordRequestForm(FlaskForm): 34 | email = StringField(_l('Email'), validators=[DataRequired(), Email()]) 35 | submit = SubmitField(_l('Request Password Reset')) 36 | 37 | 38 | class ResetPasswordForm(FlaskForm): 39 | password = PasswordField(_l('Password'), validators=[DataRequired()]) 40 | password2 = PasswordField( 41 | _l('Repeat Password'), validators=[DataRequired(), EqualTo('password')]) 42 | submit = SubmitField(_l('Reset Password')) 43 | -------------------------------------------------------------------------------- /app/auth/models.py: -------------------------------------------------------------------------------- 1 | from flask_login import UserMixin 2 | from flask import current_app 3 | from werkzeug.security import generate_password_hash, check_password_hash 4 | from app.extensions import db, login 5 | from itsdangerous import URLSafeTimedSerializer 6 | 7 | from hashlib import sha1 8 | from hashlib import md5 9 | 10 | 11 | class User(UserMixin, db.Model): 12 | id = db.Column(db.Integer, primary_key=True) 13 | username = db.Column(db.String(64), index=True, unique=True) 14 | email = db.Column(db.String(120), index=True, unique=True) 15 | password_hash = db.Column(db.String(128)) 16 | queues = db.relationship('UserQueue', back_populates='member', cascade='all, delete-orphan, merge, save-update') 17 | owned_queues = db.relationship('Queue', back_populates='admin') 18 | 19 | is_guest = db.Column(db.Boolean, default=False) 20 | ip_address = db.Column(db.String(30), default='') 21 | name_to_print = db.Column(db.String(30), default="") 22 | 23 | is_confirmed = db.Column(db.Boolean, default=False, nullable=False) 24 | confirmed_on = db.Column(db.DateTime, nullable=True) 25 | 26 | is_admin = db.Column(db.Boolean, default=False, nullable=False) 27 | 28 | like_given = db.Column(db.Boolean, default=False, nullable=False) 29 | 30 | def __repr__(self): 31 | return ''.format(self.username) 32 | 33 | def set_password(self, password): 34 | self.password_hash = generate_password_hash(password) 35 | 36 | def check_password(self, password): 37 | return check_password_hash(self.password_hash, password) 38 | 39 | def avatar(self, size): 40 | digest = md5(self.email.lower().encode('utf-8')).hexdigest() 41 | return 'https://www.gravatar.com/avatar/{}?d=identicon&s={}'.format( 42 | digest, size) 43 | 44 | @staticmethod 45 | def generate_ip_hash(ip_address): 46 | return sha1(ip_address.encode('utf-8')).hexdigest()[15:44] 47 | 48 | def set_ip_address(self, ip_address): 49 | self.ip_address = self.generate_ip_hash(ip_address) 50 | 51 | def check_ip_address(self, ip_address): 52 | return self.ip_address == self.generate_ip_hash(ip_address) 53 | 54 | def generate_reset_password_token(self): 55 | serializer = URLSafeTimedSerializer(current_app.config["SECRET_KEY"]) 56 | return serializer.dumps(self.id, salt=current_app.config["SECURITY_PASSWORD_SALT"]) 57 | 58 | @staticmethod 59 | def verify_reset_password_token(token): 60 | try: 61 | serializer = URLSafeTimedSerializer(current_app.config["SECRET_KEY"]) 62 | user_id = serializer.loads(token, salt=current_app.config["SECURITY_PASSWORD_SALT"]) 63 | except: 64 | return 65 | return User.query.get(user_id) 66 | -------------------------------------------------------------------------------- /app/auth/routes.py: -------------------------------------------------------------------------------- 1 | from app.auth import bp 2 | from flask import redirect, flash, url_for, request, render_template 3 | from werkzeug.urls import url_parse 4 | from flask_login import current_user, login_user, login_required, logout_user 5 | from flask_babel import _ 6 | from app.auth.utils import generate_token, confirm_token, send_email 7 | from app.auth.models import User 8 | from app.auth.forms import LoginForm, RegistrationForm 9 | from app.extensions import db 10 | from app.main.models import Stats, StatsEnum 11 | from app.auth.forms import ResetPasswordRequestForm, ResetPasswordForm 12 | from app.auth.utils import send_password_reset_email 13 | from datetime import datetime 14 | 15 | 16 | @bp.route('/login', methods=['POST', 'GET']) 17 | def login(): 18 | if current_user.is_authenticated: 19 | return redirect(url_for('main.index')) 20 | form = LoginForm() 21 | if form.validate_on_submit(): 22 | user = User.query.filter_by(username=form.username.data).first() 23 | if user is None or not user.check_password(form.password.data): 24 | flash(_('Invalid user or password'), 'danger') 25 | return redirect(url_for('auth.login')) 26 | 27 | login_user(user, remember=True) 28 | next_page = request.args.get('next') 29 | if next_page is None or url_parse(next_page).netloc != '': 30 | next_page = url_for('main.index') 31 | return redirect(next_page) 32 | 33 | return render_template('auth/login.html', login_form=form, title='Sign in') 34 | 35 | 36 | @bp.route('/logout') 37 | @login_required 38 | def logout(): 39 | logout_user() 40 | return redirect(url_for('main.index')) 41 | 42 | 43 | @bp.route('/register', methods=['GET', 'POST']) 44 | def register(): 45 | if not current_user.is_anonymous and current_user.is_authenticated: 46 | return redirect(url_for('main.index')) 47 | 48 | form = RegistrationForm() 49 | if form.validate_on_submit(): 50 | new_user = User(username=form.username.data, email=form.email.data) 51 | new_user.set_password(form.password.data) 52 | db.session.add(new_user) 53 | db.session.commit() 54 | 55 | token = generate_token(new_user.email) 56 | confirm_url = url_for("auth.confirm_email", user_id=new_user.id, token=token, _external=True) 57 | html = render_template("auth/email/confirm_email.html", confirm_url=confirm_url) 58 | subject = "Please confirm your email" 59 | send_email(new_user.email, subject, html) 60 | 61 | login_user(new_user) 62 | 63 | flash(_('Congratulations, you are now a registered user! Check your email for verification.'), 'success') 64 | return redirect(url_for('auth.inactive')) 65 | return render_template('auth/register.html', register_form=form, title='Register') 66 | 67 | 68 | @bp.route("/inactive") 69 | @login_required 70 | def inactive(): 71 | if current_user.is_confirmed: 72 | return redirect(url_for("main.index")) 73 | return render_template("auth/inactive.html") 74 | 75 | 76 | @bp.route("/resend_confirmation") 77 | @login_required 78 | def resend_confirmation(): 79 | if current_user.is_confirmed: 80 | flash(_("Your account has already been confirmed.", "success")) 81 | return redirect(url_for("main.index")) 82 | 83 | token = generate_token(current_user.email) 84 | confirm_url = url_for("auth.confirm_email", user_id=current_user.id, token=token, _external=True) 85 | html = render_template("auth/email/confirm_email.html", confirm_url=confirm_url) 86 | subject = "Please confirm your email" 87 | send_email(current_user.email, subject, html) 88 | 89 | flash(_("A new confirmation email has been sent."), "success") 90 | return redirect(url_for("auth.inactive")) 91 | 92 | 93 | @bp.route("/confirm_email//") 94 | def confirm_email(user_id, token): 95 | user = User.query.filter_by(id=user_id).first_or_404() 96 | if user.is_confirmed: 97 | flash(_("Account already confirmed."), "success") 98 | return redirect(url_for("main.index")) 99 | email = confirm_token(token) 100 | if user.email == email: 101 | user.is_confirmed = True 102 | user.confirmed_on = datetime.now() 103 | db.session.add(user) 104 | Stats.increase(StatsEnum.users_registered) 105 | db.session.commit() 106 | flash(_("You have confirmed your account. Thanks!"), "success") 107 | else: 108 | flash(_("The confirmation link is invalid or has expired."), "danger") 109 | return redirect(url_for("main.index")) 110 | 111 | 112 | @bp.route('/reset_password_request', methods=['GET', 'POST']) 113 | def reset_password_request(): 114 | if current_user.is_authenticated: 115 | return redirect(url_for('main.index')) 116 | form = ResetPasswordRequestForm() 117 | if form.validate_on_submit(): 118 | user = User.query.filter_by(email=form.email.data).first() 119 | if user: 120 | send_password_reset_email(user) 121 | flash(_('Check your email for the instructions to reset your password')) 122 | return redirect(url_for('auth.login')) 123 | return render_template('auth/reset_password_request.html', 124 | title='Reset Password', reset_password_request_form=form) 125 | 126 | 127 | @bp.route('/reset_password/', methods=['GET', 'POST']) 128 | def reset_password(token): 129 | if current_user.is_authenticated: 130 | return redirect(url_for('main.index')) 131 | user = User.verify_reset_password_token(token) 132 | if not user: 133 | return redirect(url_for('main.index')) 134 | form = ResetPasswordForm() 135 | if form.validate_on_submit(): 136 | user.set_password(form.password.data) 137 | db.session.commit() 138 | flash(_('Your password has been reset.')) 139 | return redirect(url_for('auth.login')) 140 | return render_template('auth/reset_password.html', reset_password_form=form) 141 | -------------------------------------------------------------------------------- /app/auth/utils.py: -------------------------------------------------------------------------------- 1 | from app.auth.models import User 2 | from itsdangerous import URLSafeTimedSerializer 3 | from flask import current_app, request, render_template 4 | from app.extensions import mail 5 | from flask_mail import Message 6 | from flask_login import current_user 7 | from flask_babel import _ 8 | import threading 9 | 10 | 11 | def cur_user_or_temp(): 12 | if not current_user.is_anonymous: 13 | return current_user 14 | temp_user = User() 15 | 16 | if request.headers.getlist("X-Forwarded-For"): 17 | ip = request.headers.getlist("X-Forwarded-For")[0] 18 | else: 19 | ip = request.remote_addr 20 | 21 | ip += request.headers.get('User-Agent') 22 | 23 | temp_user.set_ip_address(ip) 24 | 25 | maybe_user = User.query.filter_by(ip_address=temp_user.ip_address).first() 26 | 27 | if maybe_user is not None: 28 | temp_user = maybe_user 29 | else: 30 | temp_user.username = temp_user.ip_address 31 | temp_user.email = temp_user.ip_address 32 | temp_user.is_guest = True 33 | temp_user.name_to_print = "" 34 | 35 | return temp_user 36 | 37 | 38 | def generate_token(email): 39 | serializer = URLSafeTimedSerializer(current_app.config["SECRET_KEY"]) 40 | return serializer.dumps(email, salt=current_app.config["SECURITY_PASSWORD_SALT"]) 41 | 42 | 43 | def confirm_token(token, expiration=3600): 44 | serializer = URLSafeTimedSerializer(current_app.config["SECRET_KEY"]) 45 | try: 46 | email = serializer.loads( 47 | token, salt=current_app.config["SECURITY_PASSWORD_SALT"], max_age=expiration 48 | ) 49 | return email 50 | except Exception: 51 | return False 52 | 53 | 54 | class SendEmailThread(threading.Thread): 55 | def __init__(self, app, msg): 56 | self.current_app = app 57 | self.msg = msg 58 | threading.Thread.__init__(self) 59 | self.daemon = True 60 | 61 | UPDATE_FREQUENCY = 2 # in seconds 62 | 63 | def run(self): 64 | with self.current_app.app_context(): 65 | mail.send(self.msg) 66 | 67 | 68 | def send_email(to, subject, template): 69 | msg = Message( 70 | subject, 71 | recipients=[to], 72 | html=template, 73 | sender=current_app.config["MAIL_DEFAULT_SENDER"], 74 | ) 75 | 76 | SendEmailThread(current_app._get_current_object(), msg).start() 77 | 78 | 79 | def send_password_reset_email(user): 80 | token = user.generate_reset_password_token() 81 | send_email(subject=_(_('[QueueHere] Reset Your Password')), 82 | to=user.email, 83 | template=render_template('auth/email/reset_password.html', 84 | user=user, token=token)) 85 | -------------------------------------------------------------------------------- /app/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | from dotenv import load_dotenv, dotenv_values 3 | 4 | basedir = os.path.abspath(os.path.dirname(__file__)) 5 | load_dotenv(os.path.join(basedir, '.env')) 6 | 7 | 8 | class Config: 9 | MAX_OWNED_QUEUES_PER_USER = 50 10 | 11 | BABEL_DEFAULT_LOCAL = 'en' 12 | BABEL_TRANSLATION_DIRECTORIES = os.path.join(basedir, "translations") 13 | 14 | SEND_FILE_MAX_AGE_DEFAULT = 0 15 | SECRET_KEY = os.environ.get('SECRET_KEY') or 'some-key' 16 | SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \ 17 | 'sqlite:///' + os.path.join(basedir, 'app.db') 18 | SQLALCHEMY_TRACK_MODIFICATIONS = False 19 | SQLALCHEMY_ENGINE_OPTIONS = {'pool_recycle': 280, 'pool_pre_ping': True} 20 | SESSION_COOKIE_NAME = "queue_session" 21 | SESSION_TYPE = 'filesystem' 22 | SESSION_FILE_DIR = os.path.join(basedir, 'sessions') 23 | SESSION_REFRESH_EACH_REQUEST = True 24 | SECURITY_PASSWORD_SALT = os.environ.get('SECURITY_PASSWORD_SALT') or \ 25 | 'very-important' 26 | 27 | EMAIL_USER = os.environ.get('EMAIL_USER') 28 | EMAIL_PASSWORD = os.environ.get('EMAIL_PASSWORD') 29 | 30 | # Mail Settings 31 | MAIL_DEFAULT_SENDER = EMAIL_USER 32 | MAIL_SERVER = "smtp.mail.ru" 33 | MAIL_PORT = 465 34 | MAIL_USE_TLS = False 35 | MAIL_USE_SSL = True 36 | MAIL_DEBUG = False 37 | MAIL_USERNAME = EMAIL_USER 38 | MAIL_PASSWORD = EMAIL_PASSWORD 39 | -------------------------------------------------------------------------------- /app/errors/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | bp = Blueprint('errors', __name__) 4 | 5 | from app.errors import handlers 6 | -------------------------------------------------------------------------------- /app/errors/handlers.py: -------------------------------------------------------------------------------- 1 | from flask import render_template 2 | from app import db 3 | from app.errors import bp 4 | 5 | 6 | @bp.app_errorhandler(404) 7 | def not_found_error(error): 8 | return render_template('errors/404.html'), 404 9 | 10 | 11 | @bp.app_errorhandler(500) 12 | def internal_error(error): 13 | db.session.rollback() 14 | return render_template('errors/500.html'), 500 15 | -------------------------------------------------------------------------------- /app/extensions.py: -------------------------------------------------------------------------------- 1 | from flask_login import LoginManager 2 | from flask_sqlalchemy import SQLAlchemy 3 | from flask_migrate import Migrate 4 | from flask_bootstrap import Bootstrap5 5 | from flask_mail import Mail 6 | from flask_moment import Moment 7 | from flask_babel import Babel 8 | from flask_qrcode import QRcode 9 | from flask_wtf import CSRFProtect 10 | 11 | login = LoginManager() 12 | db = SQLAlchemy() 13 | migrate = Migrate() 14 | bootstrap = Bootstrap5() 15 | mail = Mail() 16 | moment = Moment() 17 | babel = Babel() 18 | qrcode = QRcode() 19 | csrf = CSRFProtect() 20 | -------------------------------------------------------------------------------- /app/main/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | bp = Blueprint('main', __name__) 4 | 5 | from app.main import routes 6 | -------------------------------------------------------------------------------- /app/main/models.py: -------------------------------------------------------------------------------- 1 | import enum 2 | 3 | from app import db 4 | 5 | 6 | class StatsEnum(enum.Enum): 7 | users_registered = "users_registered" 8 | queues_created = "queues_created" 9 | queues_entries = "queues_entries" 10 | likes_given = "likes_given" 11 | 12 | def __str__(self): 13 | return self.name 14 | 15 | 16 | class Stats(db.Model): 17 | name = db.Column(db.String(30), primary_key=True) 18 | count = db.Column(db.Integer, nullable=False, default=0) 19 | 20 | @staticmethod 21 | def get_or_create(name): 22 | stat = Stats.query.filter_by(name=name).first() 23 | if stat is None: 24 | stat = Stats(name=name, count=0) 25 | db.session.add(stat) 26 | db.session.commit() 27 | return stat 28 | 29 | @staticmethod 30 | def increase(name): 31 | Stats.get_or_create(name).count += 1 32 | 33 | @staticmethod 34 | def decrease(name): 35 | Stats.get_or_create(name).count -= 1 36 | 37 | @staticmethod 38 | def get_count_of(name): 39 | if name == StatsEnum.queues_created: 40 | return Queue.query.count() 41 | return Stats.get_or_create(name).count 42 | 43 | 44 | from app.queue.models import Queue 45 | -------------------------------------------------------------------------------- /app/main/routes.py: -------------------------------------------------------------------------------- 1 | from flask import render_template, redirect, url_for, current_app, request, session 2 | from flask_login import current_user 3 | from app.extensions import db, babel 4 | from app.queue.forms import FindQueueForm 5 | from app.main import bp 6 | from app.main.models import Stats, StatsEnum 7 | from flask_login import login_required 8 | from app.auth.decorators import check_is_confirmed 9 | 10 | 11 | @bp.route('/', methods=['POST', 'GET']) 12 | @bp.route('/index', methods=['POST', 'GET']) 13 | def index(): 14 | form = FindQueueForm() 15 | if form.validate_on_submit(): 16 | return redirect(url_for('queue.queue', queue_id=form.queue_id.data)) 17 | return render_template('main/index.html', find_queue_form=form) 18 | 19 | 20 | @bp.route('/give_like', methods=['POST']) 21 | def give_like(): 22 | if current_user.is_anonymous or not current_user.is_confirmed: 23 | return {}, 401 24 | if not current_user.like_given: 25 | current_user.like_given = True 26 | Stats.increase(StatsEnum.likes_given) 27 | db.session.commit() 28 | 29 | return {"likes_amount": Stats.get_count_of(StatsEnum.likes_given)}, 200 30 | 31 | 32 | @bp.before_app_request 33 | def before_request(): 34 | if current_user is not None and current_user.is_authenticated: 35 | db.session.commit() 36 | 37 | 38 | @bp.after_app_request 39 | def add_header(response): 40 | response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" 41 | response.headers["Pragma"] = "no-cache" 42 | response.headers["Expires"] = "0" 43 | response.cache_control.public = True 44 | response.cache_control.max_age = 0 45 | session.permanent = True 46 | 47 | return response 48 | -------------------------------------------------------------------------------- /app/queue/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | bp = Blueprint('queue', __name__) 4 | 5 | from app.queue import routes 6 | -------------------------------------------------------------------------------- /app/queue/forms.py: -------------------------------------------------------------------------------- 1 | from flask_wtf import FlaskForm 2 | from wtforms import StringField, SubmitField 3 | from wtforms.validators import DataRequired, Length 4 | from flask_babel import lazy_gettext as _l 5 | 6 | 7 | class CreateQueueForm(FlaskForm): 8 | name = StringField(_l('Name'), validators=[DataRequired(), Length(max=100)]) 9 | submit = SubmitField(_l('Create')) 10 | 11 | 12 | class JoinQueueForm(FlaskForm): 13 | name_to_print = StringField(_l('Join with name'), validators=[DataRequired(), Length(max=30)]) 14 | submit = SubmitField(_l('Join')) 15 | 16 | 17 | class FindQueueForm(FlaskForm): 18 | queue_id = StringField(_l('Queue ID'), validators=[DataRequired(), Length(min=5)]) 19 | submit = SubmitField(_l('Find')) 20 | 21 | 22 | class KillQueueForm(FlaskForm): 23 | queue_id = StringField(_l('Queue ID'), validators=[DataRequired(), Length(min=5)]) 24 | submit = SubmitField(_l('Delete')) 25 | 26 | 27 | class ForgetQueueForm(FlaskForm): 28 | queue_id = StringField(_l('Queue ID'), validators=[DataRequired(), Length(min=5)]) 29 | submit = SubmitField(_l('Forget')) 30 | -------------------------------------------------------------------------------- /app/queue/models.py: -------------------------------------------------------------------------------- 1 | import enum 2 | 3 | from app import db 4 | 5 | import shortuuid 6 | from datetime import datetime 7 | 8 | 9 | class UserQueue(db.Model): 10 | member_id = db.Column(db.Integer, db.ForeignKey('user.id', ondelete="CASCADE"), primary_key=True) 11 | queue_id = db.Column(db.String(10), db.ForeignKey('queue.id', ondelete="CASCADE"), primary_key=True) 12 | name_printed = db.Column(db.String(30), nullable=False) 13 | 14 | index_in_queue = db.Column(db.Integer, primary_key=True) 15 | arrive_time = db.Column(db.DateTime, nullable=False, default=datetime.utcnow()) 16 | is_visible = db.Column(db.Boolean, default=True, nullable=False) 17 | 18 | member = db.relationship("User", back_populates="queues", passive_deletes=True) 19 | queue = db.relationship("Queue", back_populates="members", passive_deletes=True) 20 | 21 | def update_arrive_time(self): 22 | self.arrive_time = datetime.utcnow() 23 | 24 | 25 | def generate_queue_id(): 26 | try_times = 5 27 | 28 | def try_to_generate_from(alphabet): 29 | for i in range(try_times): 30 | new_id = shortuuid.ShortUUID(alphabet=alphabet).random(length=5) 31 | if Queue.query.filter_by(id=new_id).first() is None: 32 | return new_id 33 | return None 34 | 35 | return try_to_generate_from('0123456789') or \ 36 | try_to_generate_from('ABCDEFGHIJKLMNOPQRSTUVWXYZ') or \ 37 | try_to_generate_from('abcdefghijklmnopqrstuvwxyz') or \ 38 | try_to_generate_from('0123456789ABCDEFGHJKLMNPQRSTUVWXYZ') or \ 39 | try_to_generate_from('0123456789abcdefghijklmnopqrstuvwxyz') or \ 40 | try_to_generate_from('23456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz') 41 | 42 | 43 | class Queue(db.Model): 44 | id = db.Column(db.String(10), primary_key=True, default=generate_queue_id) 45 | name = db.Column(db.String(100), nullable=False) 46 | members = db.relationship('UserQueue', back_populates='queue', cascade='all, delete, merge, save-update') 47 | tasks = db.relationship('QueueTask', back_populates='queue', cascade='all, delete, merge, save-update') 48 | last_index = db.Column(db.Integer, nullable=False, default=0) 49 | 50 | is_open = db.Column(db.Boolean, nullable=False, default=True) 51 | 52 | admin_id = db.Column(db.Integer(), db.ForeignKey('user.id'), nullable=False) 53 | admin = db.relationship('User', back_populates='owned_queues') 54 | 55 | def __repr__(self): 56 | return ''.format(self.name) 57 | 58 | def close(self): 59 | self.is_open = False 60 | 61 | def open(self): 62 | self.is_open = True 63 | 64 | def list(self): 65 | return UserQueue.query \ 66 | .filter_by(queue_id=self.id, is_visible=True) \ 67 | .order_by(UserQueue.index_in_queue) 68 | 69 | def tasks_sorted(self): 70 | return sorted(self.tasks, key=lambda task: task.execute_time) 71 | 72 | def next_clearing(self): 73 | return next(x for x in self.tasks_sorted() if x.action == TaskEnum.clear) 74 | 75 | def next_opening(self): 76 | return next(x for x in self.tasks_sorted() if x.action == TaskEnum.open) 77 | 78 | def next_closing(self): 79 | return next(x for x in self.tasks_sorted() if x.action == TaskEnum.close) 80 | 81 | def get_user_queue(self, user): 82 | return UserQueue.query.filter_by(queue_id=self.id, member_id=user.id).first() 83 | 84 | def was_in_queue(self, user): 85 | return self.get_user_queue(user) is not None 86 | 87 | def contains(self, user): 88 | return self.was_in_queue(user) and self.get_user_queue(user).is_visible 89 | 90 | def add_member(self, user, name_printed): 91 | if self.contains(user): 92 | return 93 | self.last_index += 1 94 | if self.was_in_queue(user): 95 | was_user = self.get_user_queue(user) 96 | was_user.name_printed = name_printed 97 | was_user.index_in_queue = self.last_index 98 | was_user.is_visible = True 99 | if (datetime.utcnow() - was_user.arrive_time).total_seconds() / 60 >= 5: 100 | Stats.increase(StatsEnum.queues_entries) 101 | was_user.update_arrive_time() 102 | return 103 | uq = UserQueue(name_printed=name_printed, 104 | index_in_queue=self.last_index, 105 | arrive_time=datetime.utcnow()) 106 | uq.member = user 107 | Stats.increase(StatsEnum.queues_entries) 108 | self.members.append(uq) 109 | 110 | def clear(self): 111 | for member in self.members: 112 | self.leave_member(member.member) 113 | 114 | def leave_member(self, user): 115 | UserQueue.query.filter_by(member_id=user.id, queue_id=self.id).first().is_visible = False 116 | 117 | def remove_member(self, user): 118 | UserQueue.query.filter_by(member_id=user.id, queue_id=self.id).delete() 119 | 120 | 121 | class TaskEnum(enum.Enum): 122 | clear = 1 123 | close = 2 124 | open = 3 125 | 126 | def __str__(self): 127 | return self.name 128 | 129 | 130 | class QueueTask(db.Model): 131 | id = db.Column(db.Integer, primary_key=True) 132 | 133 | queue_id = db.Column(db.String(10), db.ForeignKey('queue.id', ondelete="CASCADE"), nullable=False) 134 | queue = db.relationship("Queue", back_populates="tasks", passive_deletes=True) 135 | 136 | action = db.Column(db.Enum(TaskEnum), nullable=False) # clear, close, open 137 | execute_time = db.Column(db.DateTime, nullable=False, index=True) 138 | 139 | def __repr__(self): 140 | return "".format(self.action, self.execute_time) 141 | 142 | @staticmethod 143 | def get_nearest(): 144 | return QueueTask.query.order_by(QueueTask.execute_time).first() 145 | 146 | def execute_if_needed(self): 147 | if self.execute_time <= datetime.utcnow(): 148 | self.execute() 149 | db.session.delete(self) 150 | return True 151 | return False 152 | 153 | def execute(self): 154 | if self.action == TaskEnum.clear: 155 | self.queue.clear() 156 | elif self.action == TaskEnum.close: 157 | self.queue.close() 158 | elif self.action == TaskEnum.open: 159 | self.queue.open() 160 | 161 | 162 | from app.main.models import Stats, StatsEnum 163 | -------------------------------------------------------------------------------- /app/queue/routes.py: -------------------------------------------------------------------------------- 1 | from flask import render_template, flash, redirect, url_for, request, current_app, abort 2 | from flask_login import login_required, current_user 3 | from flask_babel import _ 4 | from app.queue.models import Queue, UserQueue, QueueTask, TaskEnum 5 | from app.auth.models import User 6 | from app.auth.utils import cur_user_or_temp 7 | from app.auth.decorators import check_is_confirmed 8 | from app.extensions import db 9 | from app.queue.forms import CreateQueueForm, JoinQueueForm, KillQueueForm, ForgetQueueForm 10 | from app.queue import bp 11 | 12 | 13 | @bp.route('/queue/', methods=['GET', 'POST']) 14 | def queue(queue_id): 15 | cur_queue = Queue.query.filter_by(id=queue_id).first_or_404() 16 | join_form = JoinQueueForm() 17 | 18 | cur_user = cur_user_or_temp() 19 | 20 | if join_form.validate_on_submit(): 21 | if not cur_queue.is_open: 22 | flash(_('Queue is closed.'), 'danger') 23 | else: 24 | if cur_queue.contains(cur_user): 25 | flash(_('You have already enter this queue.'), 'danger') 26 | return redirect(url_for('queue.queue', queue_id=queue_id)) 27 | 28 | cur_queue.add_member(cur_user, join_form.name_to_print.data) 29 | cur_user.name_to_print = join_form.name_to_print.data 30 | db.session.commit() 31 | return redirect(url_for('queue.queue', queue_id=queue_id)) 32 | 33 | return render_template('queue/queue.html', 34 | queue=cur_queue, 35 | join_queue_form=join_form, 36 | watching_user=cur_user) 37 | 38 | 39 | @bp.route('/create_queue', methods=['GET', 'POST']) 40 | @login_required 41 | @check_is_confirmed 42 | def create_queue(): 43 | form = CreateQueueForm() 44 | if form.validate_on_submit(): 45 | max_queues = current_app.config['MAX_OWNED_QUEUES_PER_USER'] 46 | if len(current_user.owned_queues) >= max_queues: 47 | flash(_(u'You cant create more %(max_queues) than queues', max_queues=max_queues), 'danger') 48 | return redirect(url_for('queue.create_queue')) 49 | 50 | new_queue = Queue(name=form.name.data, admin=current_user) 51 | 52 | db.session.add(new_queue) 53 | db.session.commit() 54 | 55 | return redirect(url_for('queue.queue', queue_id=new_queue.id)) 56 | return render_template('queue/create_queue.html', create_queue_form=form, title='Create Queue') 57 | 58 | 59 | @bp.route('/leave_queue/') 60 | def leave_queue(queue_id): 61 | cur_queue = Queue.query.filter_by(id=queue_id).first_or_404() 62 | 63 | cur_queue.leave_member(cur_user_or_temp()) 64 | 65 | db.session.commit() 66 | return redirect(url_for('queue.queue', queue_id=queue_id)) 67 | 68 | 69 | @bp.route('/forget_queue', methods=['POST']) 70 | def forget_queue(): 71 | data = request.get_json(force=True) 72 | try: 73 | cur_queue = Queue.query.filter_by(id=data['queue_id']).first() 74 | cur_queue.remove_member(cur_user_or_temp()) 75 | db.session.commit() 76 | except: 77 | return {}, 400 78 | return {}, 200 79 | 80 | 81 | @bp.route('/kill_queue', methods=['POST']) 82 | @login_required 83 | @check_is_confirmed 84 | def kill_queue(): 85 | data = request.get_json(force=True) 86 | print(data) 87 | try: 88 | cur_queue = Queue.query.filter_by(id=data['queue_id']).first() 89 | if current_user == cur_queue.admin: 90 | Queue.query.filter_by(id=data['queue_id']).delete() 91 | db.session.commit() 92 | else: 93 | return {}, 400 94 | except: 95 | return {}, 400 96 | 97 | return {}, 200 98 | 99 | 100 | @bp.route('/my_queues', methods=['GET']) 101 | @login_required 102 | @check_is_confirmed 103 | def my_queues(): 104 | kill_form = KillQueueForm() 105 | forget_form = ForgetQueueForm() 106 | return render_template('queue/my_queues.html', 107 | kill_queue_form=kill_form, 108 | forget_queue_form=forget_form) 109 | 110 | 111 | @bp.route('/manage_queue/', methods=['GET']) 112 | @login_required 113 | @check_is_confirmed 114 | def manage_queue(queue_id): 115 | cur_queue = Queue.query.filter_by(id=queue_id).first_or_404() 116 | if cur_queue.admin != current_user: 117 | abort(404) 118 | 119 | return render_template('queue/manage_queue.html', queue=cur_queue) 120 | 121 | 122 | @bp.route('/spam_queue/', methods=['GET']) 123 | @login_required 124 | def spam_queue(queue_id): 125 | cur_queue = Queue.query.filter_by(id=queue_id).first_or_404() 126 | for i in range(2, 7): 127 | spamer = User.query.filter_by(id=i).first() 128 | cur_queue.add_member(spamer, spamer.username) 129 | db.session.commit() 130 | return redirect(url_for('queue.manage_queue', queue_id=queue_id)) 131 | 132 | 133 | @bp.route('/update_queue/new_order', methods=['POST']) 134 | @login_required 135 | @check_is_confirmed 136 | def new_order(): 137 | data = request.get_json(force=True) 138 | queue_id = data['queue_id'] 139 | 140 | cur_queue = Queue.query.filter_by(id=queue_id).first_or_404() 141 | 142 | if cur_queue.admin != current_user: 143 | abort(400) 144 | 145 | order = data['new_order'] 146 | 147 | users_queue = cur_queue.list() 148 | old_indices = [] 149 | for user_queue in users_queue: 150 | old_indices.append(user_queue.index_in_queue) 151 | user_queue.is_visible = False 152 | new_users_queue = [] 153 | 154 | for i in order: 155 | new_users_queue.append(UserQueue.query.filter_by(queue_id=queue_id, member_id=int(i)).first_or_404()) 156 | 157 | for i in range(len(new_users_queue)): 158 | new_users_queue[i].index_in_queue = old_indices[i] 159 | new_users_queue[i].is_visible = True 160 | 161 | db.session.commit() 162 | return {}, 200 163 | 164 | 165 | @bp.route('/update_queue/open_queue', methods=['POST']) 166 | @login_required 167 | @check_is_confirmed 168 | def open_queue(): 169 | data = request.get_json(force=True) 170 | queue_id = data['queue_id'] 171 | 172 | cur_queue = Queue.query.filter_by(id=queue_id).first_or_404() 173 | 174 | if cur_queue.admin != current_user: 175 | abort(400) 176 | 177 | cur_queue.open() 178 | db.session.commit() 179 | return {}, 200 180 | 181 | 182 | @bp.route('/update_queue/close_queue', methods=['POST']) 183 | @login_required 184 | @check_is_confirmed 185 | def close_queue(): 186 | data = request.get_json(force=True) 187 | queue_id = data['queue_id'] 188 | 189 | cur_queue = Queue.query.filter_by(id=queue_id).first_or_404() 190 | 191 | if cur_queue.admin != current_user: 192 | abort(400) 193 | 194 | cur_queue.close() 195 | db.session.commit() 196 | return {}, 200 197 | 198 | 199 | @bp.route('/update_queue/add_task', methods=['POST']) 200 | @login_required 201 | @check_is_confirmed 202 | def add_task(): 203 | data = request.get_json(force=True) 204 | queue_id = data['queue_id'] 205 | 206 | cur_queue = Queue.query.filter_by(id=queue_id).first_or_404() 207 | 208 | if cur_queue.admin != current_user: 209 | abort(400) 210 | 211 | task_action = data['task_action'] 212 | task_time = data['task_time'] 213 | if TaskEnum[task_action] is None: 214 | abort(400) 215 | task = QueueTask(queue_id=queue_id, 216 | action=TaskEnum[task_action]) 217 | try: 218 | task.execute_time = task_time 219 | db.session.add(task) 220 | db.session.commit() 221 | except: 222 | abort(400) 223 | 224 | return {}, 200 225 | 226 | 227 | @bp.route('/get_queue_tasks/', methods=['POST', 'GET']) 228 | @login_required 229 | @check_is_confirmed 230 | def get_queue_tasks(queue_id): 231 | cur_queue = Queue.query.filter_by(id=queue_id).first_or_404() 232 | 233 | res = [] 234 | for task in cur_queue.tasks_sorted(): 235 | res.append({ 236 | 'task_action': str(task.action), 237 | 'task_time': task.execute_time, 238 | 'task_id': task.id 239 | }) 240 | return res, 200 241 | 242 | 243 | @bp.route('/delete_queue_task', methods=['POST', 'GET']) 244 | @login_required 245 | @check_is_confirmed 246 | def delete_queue_task(): 247 | data = request.get_json(force=True) 248 | task_id = data['task_id'] 249 | task = QueueTask.query.filter_by(id=task_id).first_or_404() 250 | 251 | if task.queue.admin_id != current_user.id: 252 | abort(400) 253 | 254 | QueueTask.query.filter_by(id=task_id).delete() 255 | db.session.commit() 256 | 257 | return {}, 200 258 | -------------------------------------------------------------------------------- /app/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vaniog/QueueHere/1cfdd8fbf05f3ce03dca33f9984d16aa7863ebfe/app/static/favicon.ico -------------------------------------------------------------------------------- /app/static/js/base.js: -------------------------------------------------------------------------------- 1 | theme_btn = document.getElementById('theme-btn') 2 | 3 | theme_btn.addEventListener('click', () => { 4 | let cur_theme = document.documentElement.getAttribute('data-bs-theme') 5 | if (cur_theme === 'dark') 6 | SetTheme('light') 7 | else 8 | SetTheme('dark') 9 | }) 10 | 11 | 12 | cur_version = getCookie('version') 13 | 14 | if (cur_version === null || cur_version < 1.0) { 15 | document.getElementById('info-notification').style.display = 'inline-block' 16 | } 17 | 18 | document.getElementById('update-ok-btn').addEventListener('click', () => { 19 | setCookie('version', 1.0) 20 | document.getElementById('info-notification').style.display = 'none' 21 | }) -------------------------------------------------------------------------------- /app/static/js/load_theme.js: -------------------------------------------------------------------------------- 1 | function setCookie(name, value, days) { 2 | var expires = ""; 3 | if (days) { 4 | var date = new Date(); 5 | date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000)); 6 | expires = "; expires=" + date.toUTCString(); 7 | } 8 | document.cookie = name + "=" + (value || "") + expires + "; path=/"; 9 | } 10 | 11 | function getCookie(name) { 12 | var nameEQ = name + "="; 13 | var ca = document.cookie.split(';'); 14 | for (var i = 0; i < ca.length; i++) { 15 | var c = ca[i]; 16 | while (c.charAt(0) == ' ') c = c.substring(1, c.length); 17 | if (c.indexOf(nameEQ) == 0) return c.substring(nameEQ.length, c.length); 18 | } 19 | return null; 20 | } 21 | 22 | cur_theme = getCookie('theme') 23 | 24 | if (cur_theme === null) { 25 | setCookie('theme', 'dark') 26 | cur_theme = 'dark' 27 | } 28 | 29 | function SetTheme(theme_name) { 30 | if (theme_name === 'dark') 31 | document.documentElement.setAttribute('data-bs-theme', 'dark') 32 | else 33 | document.documentElement.setAttribute('data-bs-theme', 'light') 34 | setCookie('theme', theme_name) 35 | } 36 | 37 | SetTheme(cur_theme) 38 | -------------------------------------------------------------------------------- /app/static/stylesheets/style.css: -------------------------------------------------------------------------------- 1 | .table td.fit, 2 | .table th.fit { 3 | white-space: nowrap; 4 | width: 1%; 5 | } 6 | -------------------------------------------------------------------------------- /app/templates/auth/email/confirm_email.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |

7 | {{ _('Welcome! Thanks for signing up. Please follow this link to activate your account:') }} 8 |

9 |

10 | {{ confirm_url }} 11 |

12 |
13 | 14 | 15 | -------------------------------------------------------------------------------- /app/templates/auth/email/reset_password.html: -------------------------------------------------------------------------------- 1 |

{{ _('Hello!') }}

2 |

{{ _('Your username is') }} {{ user.username }}

3 |

4 | {{ _('To reset password') }} 5 | 6 | {{ _('click this link') }} 7 | . 8 |

9 |

{{ _('Or paste it to browser address line') }}

10 |

{{ url_for('auth.reset_password', token=token, _external=True) }}

11 |

{{ _('Ignore this message, if you didnt ask for it') }}

-------------------------------------------------------------------------------- /app/templates/auth/inactive.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block app_content %} 3 | 4 |
5 |

Welcome!

6 |
7 |

8 | {{ _('You have not confirmed your account. Please check your inbox (and your spam folder) - you should have received an email with a confirmation link.') }} 9 | 10 |

11 |

12 | {{ _("Didn't get the email?") }} 13 | 14 | {{ _('Resend') }}. 15 |

16 |
17 | 18 | {% endblock %} -------------------------------------------------------------------------------- /app/templates/auth/login.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% from 'bootstrap5/form.html' import render_form %} 3 | 4 | {% block app_content %} 5 |
6 |
7 |

{{ _('Sign In') }}

8 | {{ render_form(login_form) }} 9 |
10 | {{ _('New User?') }} 11 | {{ _('Click to Register!') }} 12 |
13 |
14 | {{ _('Forget Password or Username?') }} 15 | {{ _('Reset password') }} 16 |
17 |
18 |
19 | {% endblock %} -------------------------------------------------------------------------------- /app/templates/auth/register.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% from 'bootstrap5/form.html' import render_form %} 3 | 4 | {% block app_content %} 5 |
6 |
7 |

{{ _('Register') }}

8 | {{ render_form(register_form) }} 9 |
10 |
11 | {% endblock %} -------------------------------------------------------------------------------- /app/templates/auth/reset_password.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% from 'bootstrap5/form.html' import render_form %} 3 | 4 | {% block app_content %} 5 |
6 |
7 |

{{ _('Reset password') }}

8 | {{ render_form(reset_password_form) }} 9 |
10 |
11 | {% endblock %} -------------------------------------------------------------------------------- /app/templates/auth/reset_password_request.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% from 'bootstrap5/form.html' import render_form %} 3 | 4 | {% block app_content %} 5 |
6 |
7 |

{{ _('Request password reset') }}

8 | {{ render_form(reset_password_request_form) }} 9 |
10 |
11 | {% endblock %} -------------------------------------------------------------------------------- /app/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | {% from 'bootstrap5/nav.html' import render_nav_item %} 3 | 4 | 5 | 7 | 8 | 9 | 10 | {% if title %} 11 | {{ title }} - QueueHere 12 | {% else %} 13 | QueueHere 14 | {% endif %} 15 | 16 | 17 | 18 | 19 | 20 | 22 | 23 | 24 | 25 | 26 | {% block app_link %} 27 | {% endblock %} 28 | 29 | 30 | 31 | 99 | 100 | 101 |
102 | {% with messages = get_flashed_messages(with_categories=true) %} 103 | {% if messages %} 104 |
105 |
106 |
107 | {% for category, message in messages %} 108 | 113 | {% endfor %} 114 |
115 |
116 |
117 | {% endif %} 118 | {% endwith %} 119 | 120 | {% block app_content %}{% endblock %} 121 | 122 |
123 | 124 | 156 | 157 | 158 | 193 | 194 | 197 | 198 | {{ moment.include_moment() }} 199 | {{ moment.lang(get_locale()) }} 200 | {% block app_scripts %}{% endblock %} 201 | 202 | 204 | 205 | 248 | 249 | 250 | -------------------------------------------------------------------------------- /app/templates/errors/404.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block app_content %} 4 |

{{ _('Wrong address') }}

5 |

{{ _('Back') }}

6 | {% endblock %} -------------------------------------------------------------------------------- /app/templates/errors/500.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block app_content %} 4 |

{{ _('An unexpected error has occurred') }}

5 |

{{ _('The administrator has been notified. Sorry for the inconvenience!') }}

6 |

{{ _('Back') }}

7 | {% endblock %} -------------------------------------------------------------------------------- /app/templates/main/index.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% from 'bootstrap5/form.html' import render_form %} 3 | 4 | {% block app_content %} 5 |
6 |
7 |

{{ _('Join Queue') }}

8 | {{ render_form(find_queue_form) }} 9 |
10 |
11 | 12 | {% if not current_user.is_authenticated %} 13 |
14 |
16 |
{{ _('Register to get access to all the features:') }}
17 | {{ _('Create queues') }} 18 | 19 | 20 | {{ _('Manage your queues') }} 21 | 25 | 26 | 27 |
28 |
29 | {{ _('Swap members') }} 30 | {{ _('Remove members') }} 31 | {{ _('Clear the queue') }} 32 | {{ _('Lock and unlock') }} 33 | 34 | 35 |
36 |
37 | 38 | {{ _('Add scheduled tasks') }} 39 | 43 | 44 |
45 |
46 | {{ _('Choose time, date and action') }} 47 | 48 | 49 | 50 | 51 |
52 |
53 | {{ _('Remember queues') }} 54 |
55 |
56 | {% endif %} 57 | {% endblock %} -------------------------------------------------------------------------------- /app/templates/queue/create_queue.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% from 'bootstrap5/form.html' import render_form %} 3 | 4 | {% block app_content %} 5 |
6 |
7 |

{{ _('Create queue') }}

8 | {{ render_form(create_queue_form) }} 9 |
10 |
11 | {% endblock %} -------------------------------------------------------------------------------- /app/templates/queue/manage_queue.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% from 'bootstrap5/form.html' import render_form %} 3 | 4 | {% block app_link %} 5 | 6 | {% endblock %} 7 | 8 | {% block app_content %} 9 |
10 |
11 |
12 |
13 |
14 | 16 | 17 | 18 |
19 |

20 | {{ queue.name }} 21 | 22 | 23 | 25 | 27 | 28 |

29 |
30 |
31 |
32 | 33 |
34 | 39 | 42 | 43 | 48 | 53 | 54 | 59 | 60 | {% if current_user %} 61 | 63 | 64 | 65 | {% endif %} 66 | 67 |
68 |
69 |
70 |
71 | - {{ _('Clear') }} 72 | - {{ _('Close') }} 73 | - {{ _('Open') }} 74 | - {{ _('Tasks') }} 75 | - {{ _('Spam') }} 76 |
77 |
78 | 79 |
80 |
81 |
82 | 89 |
90 | 98 | 99 | 103 | 104 | 107 |
108 |
109 | 110 | 111 | 112 |
113 |
114 |
115 | 116 | 117 |
118 | 119 | 120 | {% for user_queue in queue.list() %} 121 | 123 | 126 | 129 | 132 | 135 | 136 | {% endfor %} 137 | 138 |
124 | 125 | 127 | {{ user_queue.name_printed }} 128 | 130 | {{ moment(user_queue.arrive_time).format('L HH:mm:ss') }} 131 | 133 | 134 |
139 |
140 |
141 |
142 | 143 | 144 | {% endblock %} 145 | 146 | {% block app_scripts %} 147 | 387 | 388 | {% endblock %} -------------------------------------------------------------------------------- /app/templates/queue/my_queues.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block app_content %} 4 |
5 |
6 |

{{ _('Your queues') }}

7 | {% for user_queue in current_user.queues %} 8 |
9 | 15 | 16 |
17 | 18 | 22 | 23 | 24 | 53 |
54 |
55 | {% endfor %} 56 | 57 |

{{ _('Owned queues') }}

58 | {% for queue in current_user.owned_queues %} 59 |
60 | 66 |
67 | 68 | 72 | 73 | 74 | 102 | 103 |
104 |
105 | {% endfor %} 106 |
107 |
108 | {% endblock %} 109 | 110 | {% block app_scripts %} 111 | 174 | 175 | {% endblock %} -------------------------------------------------------------------------------- /app/templates/queue/queue.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% from 'bootstrap5/form.html' import render_form %} 3 | 4 | {% block app_content %} 5 |
6 |
7 |
8 |

9 | {{ queue.name }} 10 | 11 | {% if queue.is_open %} 12 | 13 | {% else %} 14 | 15 | 16 | {% endif %} 17 |

18 | 19 |
20 | 25 | 26 | 31 | 32 | {% if current_user == queue.admin %} 33 | 35 | 36 | 37 | {% endif %} 38 |
39 |
40 |
41 | 42 |
43 |
44 |
ID: {{ queue.id }}
45 | 46 | 48 | {{ url_for('queue.queue', queue_id=queue.id, _external=True) }} 49 | 50 |
51 | qrcode1 54 |
55 |
56 |
57 | 58 |
59 |
60 | 61 | {% if queue.next_clearing() %} 62 | 63 | 64 | 65 | 66 | {% endif %} 67 | {% if queue.next_closing() %} 68 | 69 | 70 | 71 | 72 | {% endif %} 73 | {% if queue.next_opening() %} 74 | 75 | 76 | 77 | 78 | {% endif %} 79 |
{{ _('Next clearing') }}{{ moment(queue.next_clearing().execute_time).calendar() }}
{{ _('Next closing') }}{{ moment(queue.next_closing().execute_time).calendar() }}
{{ _('Next opening') }}{{ moment(queue.next_opening().execute_time).calendar() }}
80 |
81 |
82 | 83 | {% if not queue.contains(watching_user) %} 84 | {% if queue.is_open %} 85 |
86 |
87 | 88 | {{ join_queue_form.hidden_tag() }} 89 |
90 | {{ join_queue_form.name_to_print(class="form-control", 91 | value=watching_user.name_to_print, 92 | placeholder="Enter your name") }} 93 |
94 |
95 | {{ join_queue_form.submit(class="btn btn-primary", type="submit") }} 96 |
97 |
98 |
99 | {% endif %} 100 | {% else %} 101 | 108 | {% endif %} 109 | 110 | 111 | {% for user_queue in queue.list() %} 112 | 113 | 116 | {{ user_queue.name_printed }} 117 | 118 | 121 | 122 | 123 | {% endfor %} 124 |
119 | {{ moment(user_queue.arrive_time).format('L HH:mm:ss') }} 120 |
125 |
126 |
127 |
128 | 129 | {% endblock %} 130 | 131 | {% block app_scripts %} 132 | 134 | {% endblock %} -------------------------------------------------------------------------------- /app/translations/ru/LC_MESSAGES/messages.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vaniog/QueueHere/1cfdd8fbf05f3ce03dca33f9984d16aa7863ebfe/app/translations/ru/LC_MESSAGES/messages.mo -------------------------------------------------------------------------------- /app/translations/ru/LC_MESSAGES/messages.po: -------------------------------------------------------------------------------- 1 | # Russian translations for PROJECT. 2 | # Copyright (C) 2023 ORGANIZATION 3 | # This file is distributed under the same license as the PROJECT project. 4 | # FIRST AUTHOR , 2023. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: PROJECT VERSION\n" 9 | "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" 10 | "POT-Creation-Date: 2023-05-05 14:33+0300\n" 11 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 12 | "Last-Translator: FULL NAME \n" 13 | "Language: ru\n" 14 | "Language-Team: ru \n" 15 | "Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && " 16 | "n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" 17 | "MIME-Version: 1.0\n" 18 | "Content-Type: text/plain; charset=utf-8\n" 19 | "Content-Transfer-Encoding: 8bit\n" 20 | "Generated-By: Babel 2.12.1\n" 21 | 22 | #: app/__init__.py:42 23 | msgid "Please login to access this page" 24 | msgstr "Пожалуйста войдите в аккаунт, чтобы получить доступ к данной странице" 25 | 26 | #: app/auth/decorators.py:11 27 | msgid "Please confirm your account!" 28 | msgstr "Пожалуйста, подтвердите свой аккаунт!" 29 | 30 | #: app/auth/forms.py:9 app/auth/forms.py:15 31 | msgid "Username" 32 | msgstr "Логин" 33 | 34 | #: app/auth/forms.py:10 app/auth/forms.py:17 app/auth/forms.py:39 35 | msgid "Password" 36 | msgstr "Пароль" 37 | 38 | #: app/auth/forms.py:11 app/templates/auth/login.html:7 39 | msgid "Sign In" 40 | msgstr "Вход" 41 | 42 | #: app/auth/forms.py:16 app/auth/forms.py:34 43 | msgid "Email" 44 | msgstr "Email" 45 | 46 | #: app/auth/forms.py:19 app/auth/forms.py:41 47 | msgid "Repeat Password" 48 | msgstr "Повторите пароль" 49 | 50 | #: app/auth/forms.py:20 app/templates/auth/register.html:7 51 | #: app/templates/base.html:75 52 | msgid "Register" 53 | msgstr "Регистрация" 54 | 55 | #: app/auth/forms.py:25 56 | msgid "This address is already in use." 57 | msgstr "Этот адрес уже используется" 58 | 59 | #: app/auth/forms.py:30 60 | msgid "This username is already in use." 61 | msgstr "Этот логин уже используется" 62 | 63 | #: app/auth/forms.py:35 64 | msgid "Request Password Reset" 65 | msgstr "Запросить изменение пароля" 66 | 67 | #: app/auth/forms.py:42 68 | msgid "Reset Password" 69 | msgstr "Изменить пароль" 70 | 71 | #: app/auth/routes.py:24 72 | msgid "Invalid user or password" 73 | msgstr "Неверный логин или пароль" 74 | 75 | #: app/auth/routes.py:63 76 | msgid "" 77 | "Congratulations, you are now a registered user! Check your email for " 78 | "verification." 79 | msgstr "" 80 | "Поздравляем с регистрацией! Проверьте свою почту для подтверждения " 81 | "аккаунта. Проверьте спам. Если вы используете Яндекс почту, для открытия " 82 | "ссылки из письма, надо подтвердить что письмо не спам." 83 | 84 | #: app/auth/routes.py:80 85 | msgid "Your account has already been confirmed." 86 | msgstr "Ваш аккаунт уже подтвержден" 87 | 88 | #: app/auth/routes.py:89 89 | msgid "A new confirmation email has been sent." 90 | msgstr "Новое письмо с подтверждением было отправлено" 91 | 92 | #: app/auth/routes.py:97 93 | msgid "Account already confirmed." 94 | msgstr "Аккаунт уже подтвержден" 95 | 96 | #: app/auth/routes.py:106 97 | msgid "You have confirmed your account. Thanks!" 98 | msgstr "Вы подтвердили свой аккаунт! Спасибо!" 99 | 100 | #: app/auth/routes.py:108 101 | msgid "The confirmation link is invalid or has expired." 102 | msgstr "Ссылка подверждения неверная или истекла" 103 | 104 | #: app/auth/routes.py:121 105 | msgid "Check your email for the instructions to reset your password" 106 | msgstr "Проверьте вашу почту, вам придет инструкция" 107 | 108 | #: app/auth/routes.py:138 109 | msgid "Your password has been reset." 110 | msgstr "Ваш пароль был изменен" 111 | 112 | #: app/auth/utils.py:81 113 | msgid "[QueueHere] Reset Your Password" 114 | msgstr "[QueueHere] Изменение пароля" 115 | 116 | #: app/queue/forms.py:8 117 | msgid "Name" 118 | msgstr "Название" 119 | 120 | #: app/queue/forms.py:9 121 | msgid "Create" 122 | msgstr "Создать" 123 | 124 | #: app/queue/forms.py:13 125 | msgid "Join with name" 126 | msgstr "Войти под именем" 127 | 128 | #: app/queue/forms.py:14 129 | msgid "Join" 130 | msgstr "Войти" 131 | 132 | #: app/queue/forms.py:18 app/queue/forms.py:23 app/queue/forms.py:28 133 | msgid "Queue ID" 134 | msgstr "ID Очереди" 135 | 136 | #: app/queue/forms.py:19 app/templates/base.html:49 137 | msgid "Find" 138 | msgstr "Найти" 139 | 140 | #: app/queue/forms.py:24 141 | msgid "Delete" 142 | msgstr "Удалить" 143 | 144 | #: app/queue/forms.py:29 app/templates/queue/my_queues.html:20 145 | msgid "Forget" 146 | msgstr "Забыть" 147 | 148 | #: app/queue/routes.py:22 149 | msgid "Queue is closed." 150 | msgstr "Очередь закрыта" 151 | 152 | #: app/queue/routes.py:25 153 | msgid "You have already enter this queue." 154 | msgstr "Вы уже вошли в эту очередь" 155 | 156 | #: app/queue/routes.py:47 157 | msgid "You cant create more %(max_queues) than queues" 158 | msgstr "Вы не можете создать больше чем %(max_queues) очередей" 159 | 160 | #: app/templates/base.html:54 161 | msgid "New" 162 | msgstr "Создать" 163 | 164 | #: app/templates/base.html:59 app/templates/base.html:170 165 | msgid "Queues" 166 | msgstr "Очереди" 167 | 168 | #: app/templates/base.html:63 169 | msgid "Logout" 170 | msgstr "Выйти" 171 | 172 | #: app/templates/base.html:71 173 | msgid "Login" 174 | msgstr "Войти" 175 | 176 | #: app/templates/base.html:116 177 | msgid "I updated the site a little bit" 178 | msgstr "Я немного обновил сайт" 179 | 180 | #: app/templates/base.html:123 181 | msgid "" 182 | "Now you can log into the account normally and it will not log out for a " 183 | "long time, if you forget your password, you can reset it" 184 | msgstr "" 185 | "Теперь можно нормально войти в аккаунт и он не выйдет в течение " 186 | "длительного срока, а если забыли пароль, то вы сможете его восстановить" 187 | 188 | #: app/templates/base.html:127 189 | msgid "" 190 | "Which I advise you to do, because it will automatically fill in the name " 191 | "when you enter the queue, as well as always be at hand will be a " 192 | "convenient list of your queues" 193 | msgstr "" 194 | " Что я и советую сделать, так как это даст автоматическое заполнение " 195 | "имени при входе в очередь, а так же под рукой всегда будет удобный список" 196 | " ваших очередей " 197 | 198 | #: app/templates/base.html:131 199 | msgid "" 200 | "And now you can support me morally by logging into your account and " 201 | "clicking on the heart at the bottom of the site" 202 | msgstr "" 203 | "А еще теперь вы можете морально меня поддержать, войдя в аккаунт и " 204 | "кликнув на сердечко внизу сайта " 205 | 206 | #: app/templates/base.html:136 207 | msgid "Sure" 208 | msgstr "Понял" 209 | 210 | #: app/templates/base.html:150 211 | msgid "Stats" 212 | msgstr "Статистика" 213 | 214 | #: app/templates/base.html:169 215 | msgid "Users" 216 | msgstr "Пользователи" 217 | 218 | #: app/templates/base.html:171 219 | msgid "Queues entries" 220 | msgstr "Входов в очередь" 221 | 222 | #: app/templates/base.html:197 223 | msgid "Login and confirm you account" 224 | msgstr "Войдите и подтвердите аккаунт" 225 | 226 | #: app/templates/auth/inactive.html:8 227 | msgid "" 228 | "You have not confirmed your account. Please check your inbox (and your " 229 | "spam folder) - you should have received an email with a confirmation " 230 | "link." 231 | msgstr "" 232 | "Вы не подтвердили свою учетную запись. Пожалуйста, проверьте свой " 233 | "почтовый ящик (и папку спам) - вы должны были получить письмо со ссылкой " 234 | "для подтверждения. Если вы используете Яндекс почту, для открытия ссылки " 235 | "из письма, надо подтвердить что письмо не спам." 236 | 237 | #: app/templates/auth/inactive.html:12 238 | msgid "Didn't get the email?" 239 | msgstr "Не получили письмо?" 240 | 241 | #: app/templates/auth/inactive.html:14 242 | msgid "Resend" 243 | msgstr "Отправить еще раз" 244 | 245 | #: app/templates/auth/login.html:10 246 | msgid "New User?" 247 | msgstr "Новый пользователь?" 248 | 249 | #: app/templates/auth/login.html:11 250 | msgid "Click to Register!" 251 | msgstr "Нажмите для регистрации!" 252 | 253 | #: app/templates/auth/login.html:14 254 | msgid "Forget Password or Username?" 255 | msgstr "Забыли пароль или логин?" 256 | 257 | #: app/templates/auth/login.html:15 app/templates/auth/reset_password.html:7 258 | msgid "Reset password" 259 | msgstr "Изменить пароль" 260 | 261 | #: app/templates/auth/reset_password_request.html:7 262 | msgid "Request password reset" 263 | msgstr "Запросить изменение пароля" 264 | 265 | #: app/templates/auth/email/confirm_email.html:7 266 | msgid "" 267 | "Welcome! Thanks for signing up. Please follow this link to activate your " 268 | "account:" 269 | msgstr "" 270 | "Добро пожаловать! Спасибо за регистрацию. Перейдите по данной ссылки для " 271 | "активации аккаунта" 272 | 273 | #: app/templates/auth/email/reset_password.html:1 274 | msgid "Hello!" 275 | msgstr "Здравствуйте!" 276 | 277 | #: app/templates/auth/email/reset_password.html:2 278 | msgid "Your username is" 279 | msgstr "Ваш логин" 280 | 281 | #: app/templates/auth/email/reset_password.html:4 282 | msgid "To reset password" 283 | msgstr "Чтобы изменить пароль" 284 | 285 | #: app/templates/auth/email/reset_password.html:6 286 | msgid "click this link" 287 | msgstr "кликните по этой ссылке" 288 | 289 | #: app/templates/auth/email/reset_password.html:9 290 | msgid "Or paste it to browser address line" 291 | msgstr "Или вставьте ее в адресную строку браузера" 292 | 293 | #: app/templates/auth/email/reset_password.html:11 294 | msgid "Ignore this message, if you didnt ask for it" 295 | msgstr "Игнорируйте данное сообщение, если вы не запрашивали его" 296 | 297 | #: app/templates/errors/404.html:4 298 | msgid "Wrong address" 299 | msgstr "Неверный адрес" 300 | 301 | #: app/templates/errors/404.html:5 app/templates/errors/500.html:6 302 | msgid "Back" 303 | msgstr "Вернуться" 304 | 305 | #: app/templates/errors/500.html:4 306 | msgid "An unexpected error has occurred" 307 | msgstr "Возникла непредвиденная ошибка" 308 | 309 | #: app/templates/errors/500.html:5 310 | msgid "The administrator has been notified. Sorry for the inconvenience!" 311 | msgstr "Администратор был уведомлен, извините за проблемы!" 312 | 313 | #: app/templates/main/index.html:7 314 | msgid "Join Queue" 315 | msgstr "Присоединиться" 316 | 317 | #: app/templates/main/index.html:16 318 | msgid "Register to get access to all the features:" 319 | msgstr "Зарегистрируйся чтобы получить доступ ко всем возможностям" 320 | 321 | #: app/templates/main/index.html:17 322 | msgid "Create queues" 323 | msgstr "Создавай очереди" 324 | 325 | #: app/templates/main/index.html:20 326 | msgid "Manage your queues" 327 | msgstr "Управляй очередями" 328 | 329 | #: app/templates/main/index.html:29 330 | msgid "Swap members" 331 | msgstr "Перемещай участников" 332 | 333 | #: app/templates/main/index.html:30 334 | msgid "Remove members" 335 | msgstr "Удаляй участников" 336 | 337 | #: app/templates/main/index.html:31 338 | msgid "Clear the queue" 339 | msgstr "Очищай очередь" 340 | 341 | #: app/templates/main/index.html:32 342 | msgid "Lock and unlock" 343 | msgstr "Закрывай и открывай" 344 | 345 | #: app/templates/main/index.html:38 346 | msgid "Add scheduled tasks" 347 | msgstr "Добавляй запланированные задачи" 348 | 349 | #: app/templates/main/index.html:46 350 | msgid "Choose time, date and action" 351 | msgstr "Выбери время, дату и действие" 352 | 353 | #: app/templates/main/index.html:53 354 | msgid "Remember queues" 355 | msgstr "Запоминай очереди" 356 | 357 | #: app/templates/queue/create_queue.html:7 358 | msgid "Create queue" 359 | msgstr "Создать очередь" 360 | 361 | #: app/templates/queue/manage_queue.html:71 362 | #: app/templates/queue/manage_queue.html:95 363 | msgid "Clear" 364 | msgstr "Очистить" 365 | 366 | #: app/templates/queue/manage_queue.html:72 367 | #: app/templates/queue/manage_queue.html:93 368 | msgid "Close" 369 | msgstr "Закрыть" 370 | 371 | #: app/templates/queue/manage_queue.html:73 372 | #: app/templates/queue/manage_queue.html:94 373 | msgid "Open" 374 | msgstr "Открыть" 375 | 376 | #: app/templates/queue/manage_queue.html:74 377 | msgid "Tasks" 378 | msgstr "Задачи" 379 | 380 | #: app/templates/queue/manage_queue.html:75 381 | msgid "Spam" 382 | msgstr "Спам" 383 | 384 | #: app/templates/queue/manage_queue.html:105 385 | msgid "Add task" 386 | msgstr "Добавить" 387 | 388 | #: app/templates/queue/my_queues.html:6 389 | msgid "Your queues" 390 | msgstr "Ваши очереди" 391 | 392 | #: app/templates/queue/my_queues.html:31 393 | msgid "Are you sure you want to forget?" 394 | msgstr "Вы уверены что хотите забыть?" 395 | 396 | #: app/templates/queue/my_queues.html:37 397 | msgid "This action cannot be undone" 398 | msgstr "Это действие не может быть отменено" 399 | 400 | #: app/templates/queue/my_queues.html:41 app/templates/queue/my_queues.html:91 401 | msgid "No" 402 | msgstr "Нет" 403 | 404 | #: app/templates/queue/my_queues.html:47 app/templates/queue/my_queues.html:96 405 | msgid "Yes" 406 | msgstr "Да" 407 | 408 | #: app/templates/queue/my_queues.html:57 409 | msgid "Owned queues" 410 | msgstr "Созданные очереди" 411 | 412 | #: app/templates/queue/my_queues.html:81 413 | msgid "Are you sure you want to delete?" 414 | msgstr "Вы уверены что хотите удалить?" 415 | 416 | #: app/templates/queue/my_queues.html:87 417 | msgid "This action cannot be undone." 418 | msgstr "Это действие не может быть отменено" 419 | 420 | #: app/templates/queue/queue.html:63 421 | msgid "Next clearing" 422 | msgstr "Следующая очистка" 423 | 424 | #: app/templates/queue/queue.html:69 425 | msgid "Next closing" 426 | msgstr "Следующее закрытие" 427 | 428 | #: app/templates/queue/queue.html:75 429 | msgid "Next opening" 430 | msgstr "Следующее открытие" 431 | 432 | #: app/templates/queue/queue.html:104 433 | msgid "Leave" 434 | msgstr "Выйти" 435 | 436 | #~ msgid "Updated" 437 | #~ msgstr "Сохранено" 438 | 439 | #~ msgid "Save" 440 | #~ msgstr "Сохранить" 441 | 442 | #~ msgid "Error" 443 | #~ msgstr "Ошибка" 444 | 445 | #~ msgid "Link has been copied" 446 | #~ msgstr "Ссылка была скопирована" 447 | 448 | #~ msgid "Link" 449 | #~ msgstr "Ссылка" 450 | 451 | #~ msgid "Select action" 452 | #~ msgstr "Выберите задачу" 453 | 454 | #~ msgid "Select time" 455 | #~ msgstr "Выберите время" 456 | 457 | #~ msgid "New Queue" 458 | #~ msgstr "Создать" 459 | 460 | #~ msgid "Task" 461 | #~ msgstr "Задача" 462 | 463 | #~ msgid "Time" 464 | #~ msgstr "Время" 465 | 466 | #~ msgid "Remember Me" 467 | #~ msgstr "Запомнить меня" 468 | 469 | -------------------------------------------------------------------------------- /app/utils.py: -------------------------------------------------------------------------------- 1 | from flask import request, current_app 2 | 3 | 4 | def get_locale(): 5 | return request.accept_languages.best_match(['en', 'ru']) 6 | -------------------------------------------------------------------------------- /babel.cfg: -------------------------------------------------------------------------------- 1 | [python: app/**.py] 2 | [jinja2: app/**/templates/**.html] 3 | -------------------------------------------------------------------------------- /bots/taskbot/taskbot.py: -------------------------------------------------------------------------------- 1 | import threading 2 | import time 3 | from app.extensions import db 4 | from app.queue.models import QueueTask 5 | 6 | 7 | class TaskBotThread(threading.Thread): 8 | def __init__(self, current_app): 9 | self.current_app = current_app 10 | threading.Thread.__init__(self) 11 | self.daemon = True 12 | 13 | UPDATE_FREQUENCY = 2 # in seconds 14 | 15 | def run(self): 16 | with self.current_app.app_context(): 17 | while True: 18 | nearest = QueueTask.get_nearest() 19 | if nearest is not None and nearest.execute_if_needed(): 20 | db.session.commit() 21 | time.sleep(self.UPDATE_FREQUENCY) 22 | db.session.close() 23 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3.3' 2 | volumes: 3 | data: 4 | services: 5 | database: 6 | image: mariadb:latest 7 | restart: always 8 | env_file: 9 | - app/.env 10 | volumes: 11 | - ./data:/var/lib/mysql 12 | adminer: 13 | image: adminer 14 | container_name: adminer 15 | restart: always 16 | ports: 17 | - "1000:8000" 18 | depends_on: 19 | - database 20 | backend: 21 | restart: unless-stopped 22 | build: 23 | context: . 24 | dockerfile: ./docker/backend/Dockerfile 25 | command: 26 | - ./docker/backend/run.sh 27 | container_name: backend 28 | env_file: 29 | - app/.env 30 | volumes: 31 | - ./media:/backend/media:consistent 32 | - ./static:/backend/static:consistent 33 | depends_on: 34 | - database 35 | ports: 36 | - "8000:8000" 37 | -------------------------------------------------------------------------------- /docker/backend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.10.12 2 | 3 | SHELL ["/bin/bash", "-c"] 4 | 5 | ENV PYTHONDONTWRITEBYTECODE 1 6 | ENV PYTHONUNBUFFERED 1 7 | EXPOSE 8000 8 | 9 | RUN pip install --upgrade pip 10 | 11 | WORKDIR / 12 | 13 | COPY requirements.txt . 14 | 15 | RUN pip install -r requirements.txt 16 | RUN pip install gunicorn 17 | 18 | COPY . . 19 | 20 | RUN ["chmod", "+x", "./docker/backend/run.sh"] -------------------------------------------------------------------------------- /docker/backend/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | flask db upgrade 4 | gunicorn -w 4 'queue_app:create_app()' -b 0.0.0.0 5 | -------------------------------------------------------------------------------- /messages.pot: -------------------------------------------------------------------------------- 1 | # Translations template for PROJECT. 2 | # Copyright (C) 2023 ORGANIZATION 3 | # This file is distributed under the same license as the PROJECT project. 4 | # FIRST AUTHOR , 2023. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PROJECT VERSION\n" 10 | "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" 11 | "POT-Creation-Date: 2023-05-05 14:33+0300\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=utf-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "Generated-By: Babel 2.12.1\n" 19 | 20 | #: app/__init__.py:42 21 | msgid "Please login to access this page" 22 | msgstr "" 23 | 24 | #: app/auth/decorators.py:11 25 | msgid "Please confirm your account!" 26 | msgstr "" 27 | 28 | #: app/auth/forms.py:9 app/auth/forms.py:15 29 | msgid "Username" 30 | msgstr "" 31 | 32 | #: app/auth/forms.py:10 app/auth/forms.py:17 app/auth/forms.py:39 33 | msgid "Password" 34 | msgstr "" 35 | 36 | #: app/auth/forms.py:11 app/templates/auth/login.html:7 37 | msgid "Sign In" 38 | msgstr "" 39 | 40 | #: app/auth/forms.py:16 app/auth/forms.py:34 41 | msgid "Email" 42 | msgstr "" 43 | 44 | #: app/auth/forms.py:19 app/auth/forms.py:41 45 | msgid "Repeat Password" 46 | msgstr "" 47 | 48 | #: app/auth/forms.py:20 app/templates/auth/register.html:7 49 | #: app/templates/base.html:75 50 | msgid "Register" 51 | msgstr "" 52 | 53 | #: app/auth/forms.py:25 54 | msgid "This address is already in use." 55 | msgstr "" 56 | 57 | #: app/auth/forms.py:30 58 | msgid "This username is already in use." 59 | msgstr "" 60 | 61 | #: app/auth/forms.py:35 62 | msgid "Request Password Reset" 63 | msgstr "" 64 | 65 | #: app/auth/forms.py:42 66 | msgid "Reset Password" 67 | msgstr "" 68 | 69 | #: app/auth/routes.py:24 70 | msgid "Invalid user or password" 71 | msgstr "" 72 | 73 | #: app/auth/routes.py:63 74 | msgid "" 75 | "Congratulations, you are now a registered user! Check your email for " 76 | "verification." 77 | msgstr "" 78 | 79 | #: app/auth/routes.py:80 80 | msgid "Your account has already been confirmed." 81 | msgstr "" 82 | 83 | #: app/auth/routes.py:89 84 | msgid "A new confirmation email has been sent." 85 | msgstr "" 86 | 87 | #: app/auth/routes.py:97 88 | msgid "Account already confirmed." 89 | msgstr "" 90 | 91 | #: app/auth/routes.py:106 92 | msgid "You have confirmed your account. Thanks!" 93 | msgstr "" 94 | 95 | #: app/auth/routes.py:108 96 | msgid "The confirmation link is invalid or has expired." 97 | msgstr "" 98 | 99 | #: app/auth/routes.py:121 100 | msgid "Check your email for the instructions to reset your password" 101 | msgstr "" 102 | 103 | #: app/auth/routes.py:138 104 | msgid "Your password has been reset." 105 | msgstr "" 106 | 107 | #: app/auth/utils.py:81 108 | msgid "[QueueHere] Reset Your Password" 109 | msgstr "" 110 | 111 | #: app/queue/forms.py:8 112 | msgid "Name" 113 | msgstr "" 114 | 115 | #: app/queue/forms.py:9 116 | msgid "Create" 117 | msgstr "" 118 | 119 | #: app/queue/forms.py:13 120 | msgid "Join with name" 121 | msgstr "" 122 | 123 | #: app/queue/forms.py:14 124 | msgid "Join" 125 | msgstr "" 126 | 127 | #: app/queue/forms.py:18 app/queue/forms.py:23 app/queue/forms.py:28 128 | msgid "Queue ID" 129 | msgstr "" 130 | 131 | #: app/queue/forms.py:19 app/templates/base.html:49 132 | msgid "Find" 133 | msgstr "" 134 | 135 | #: app/queue/forms.py:24 136 | msgid "Delete" 137 | msgstr "" 138 | 139 | #: app/queue/forms.py:29 app/templates/queue/my_queues.html:20 140 | msgid "Forget" 141 | msgstr "" 142 | 143 | #: app/queue/routes.py:22 144 | msgid "Queue is closed." 145 | msgstr "" 146 | 147 | #: app/queue/routes.py:25 148 | msgid "You have already enter this queue." 149 | msgstr "" 150 | 151 | #: app/queue/routes.py:47 152 | msgid "You cant create more %(max_queues) than queues" 153 | msgstr "" 154 | 155 | #: app/templates/base.html:54 156 | msgid "New" 157 | msgstr "" 158 | 159 | #: app/templates/base.html:59 app/templates/base.html:170 160 | msgid "Queues" 161 | msgstr "" 162 | 163 | #: app/templates/base.html:63 164 | msgid "Logout" 165 | msgstr "" 166 | 167 | #: app/templates/base.html:71 168 | msgid "Login" 169 | msgstr "" 170 | 171 | #: app/templates/base.html:116 172 | msgid "I updated the site a little bit" 173 | msgstr "" 174 | 175 | #: app/templates/base.html:123 176 | msgid "" 177 | "Now you can log into the account normally and it will not log out for a " 178 | "long time, if you forget your password, you can reset it" 179 | msgstr "" 180 | 181 | #: app/templates/base.html:127 182 | msgid "" 183 | "Which I advise you to do, because it will automatically fill in the name " 184 | "when you enter the queue, as well as always be at hand will be a " 185 | "convenient list of your queues" 186 | msgstr "" 187 | 188 | #: app/templates/base.html:131 189 | msgid "" 190 | "And now you can support me morally by logging into your account and " 191 | "clicking on the heart at the bottom of the site" 192 | msgstr "" 193 | 194 | #: app/templates/base.html:136 195 | msgid "Sure" 196 | msgstr "" 197 | 198 | #: app/templates/base.html:150 199 | msgid "Stats" 200 | msgstr "" 201 | 202 | #: app/templates/base.html:169 203 | msgid "Users" 204 | msgstr "" 205 | 206 | #: app/templates/base.html:171 207 | msgid "Queues entries" 208 | msgstr "" 209 | 210 | #: app/templates/base.html:197 211 | msgid "Login and confirm you account" 212 | msgstr "" 213 | 214 | #: app/templates/auth/inactive.html:8 215 | msgid "" 216 | "You have not confirmed your account. Please check your inbox (and your " 217 | "spam folder) - you should have received an email with a confirmation " 218 | "link." 219 | msgstr "" 220 | 221 | #: app/templates/auth/inactive.html:12 222 | msgid "Didn't get the email?" 223 | msgstr "" 224 | 225 | #: app/templates/auth/inactive.html:14 226 | msgid "Resend" 227 | msgstr "" 228 | 229 | #: app/templates/auth/login.html:10 230 | msgid "New User?" 231 | msgstr "" 232 | 233 | #: app/templates/auth/login.html:11 234 | msgid "Click to Register!" 235 | msgstr "" 236 | 237 | #: app/templates/auth/login.html:14 238 | msgid "Forget Password or Username?" 239 | msgstr "" 240 | 241 | #: app/templates/auth/login.html:15 app/templates/auth/reset_password.html:7 242 | msgid "Reset password" 243 | msgstr "" 244 | 245 | #: app/templates/auth/reset_password_request.html:7 246 | msgid "Request password reset" 247 | msgstr "" 248 | 249 | #: app/templates/auth/email/confirm_email.html:7 250 | msgid "" 251 | "Welcome! Thanks for signing up. Please follow this link to activate your " 252 | "account:" 253 | msgstr "" 254 | 255 | #: app/templates/auth/email/reset_password.html:1 256 | msgid "Hello!" 257 | msgstr "" 258 | 259 | #: app/templates/auth/email/reset_password.html:2 260 | msgid "Your username is" 261 | msgstr "" 262 | 263 | #: app/templates/auth/email/reset_password.html:4 264 | msgid "To reset password" 265 | msgstr "" 266 | 267 | #: app/templates/auth/email/reset_password.html:6 268 | msgid "click this link" 269 | msgstr "" 270 | 271 | #: app/templates/auth/email/reset_password.html:9 272 | msgid "Or paste it to browser address line" 273 | msgstr "" 274 | 275 | #: app/templates/auth/email/reset_password.html:11 276 | msgid "Ignore this message, if you didnt ask for it" 277 | msgstr "" 278 | 279 | #: app/templates/errors/404.html:4 280 | msgid "Wrong address" 281 | msgstr "" 282 | 283 | #: app/templates/errors/404.html:5 app/templates/errors/500.html:6 284 | msgid "Back" 285 | msgstr "" 286 | 287 | #: app/templates/errors/500.html:4 288 | msgid "An unexpected error has occurred" 289 | msgstr "" 290 | 291 | #: app/templates/errors/500.html:5 292 | msgid "The administrator has been notified. Sorry for the inconvenience!" 293 | msgstr "" 294 | 295 | #: app/templates/main/index.html:7 296 | msgid "Join Queue" 297 | msgstr "" 298 | 299 | #: app/templates/main/index.html:16 300 | msgid "Register to get access to all the features:" 301 | msgstr "" 302 | 303 | #: app/templates/main/index.html:17 304 | msgid "Create queues" 305 | msgstr "" 306 | 307 | #: app/templates/main/index.html:20 308 | msgid "Manage your queues" 309 | msgstr "" 310 | 311 | #: app/templates/main/index.html:29 312 | msgid "Swap members" 313 | msgstr "" 314 | 315 | #: app/templates/main/index.html:30 316 | msgid "Remove members" 317 | msgstr "" 318 | 319 | #: app/templates/main/index.html:31 320 | msgid "Clear the queue" 321 | msgstr "" 322 | 323 | #: app/templates/main/index.html:32 324 | msgid "Lock and unlock" 325 | msgstr "" 326 | 327 | #: app/templates/main/index.html:38 328 | msgid "Add scheduled tasks" 329 | msgstr "" 330 | 331 | #: app/templates/main/index.html:46 332 | msgid "Choose time, date and action" 333 | msgstr "" 334 | 335 | #: app/templates/main/index.html:53 336 | msgid "Remember queues" 337 | msgstr "" 338 | 339 | #: app/templates/queue/create_queue.html:7 340 | msgid "Create queue" 341 | msgstr "" 342 | 343 | #: app/templates/queue/manage_queue.html:71 344 | #: app/templates/queue/manage_queue.html:95 345 | msgid "Clear" 346 | msgstr "" 347 | 348 | #: app/templates/queue/manage_queue.html:72 349 | #: app/templates/queue/manage_queue.html:93 350 | msgid "Close" 351 | msgstr "" 352 | 353 | #: app/templates/queue/manage_queue.html:73 354 | #: app/templates/queue/manage_queue.html:94 355 | msgid "Open" 356 | msgstr "" 357 | 358 | #: app/templates/queue/manage_queue.html:74 359 | msgid "Tasks" 360 | msgstr "" 361 | 362 | #: app/templates/queue/manage_queue.html:75 363 | msgid "Spam" 364 | msgstr "" 365 | 366 | #: app/templates/queue/manage_queue.html:105 367 | msgid "Add task" 368 | msgstr "" 369 | 370 | #: app/templates/queue/my_queues.html:6 371 | msgid "Your queues" 372 | msgstr "" 373 | 374 | #: app/templates/queue/my_queues.html:31 375 | msgid "Are you sure you want to forget?" 376 | msgstr "" 377 | 378 | #: app/templates/queue/my_queues.html:37 379 | msgid "This action cannot be undone" 380 | msgstr "" 381 | 382 | #: app/templates/queue/my_queues.html:41 app/templates/queue/my_queues.html:91 383 | msgid "No" 384 | msgstr "" 385 | 386 | #: app/templates/queue/my_queues.html:47 app/templates/queue/my_queues.html:96 387 | msgid "Yes" 388 | msgstr "" 389 | 390 | #: app/templates/queue/my_queues.html:57 391 | msgid "Owned queues" 392 | msgstr "" 393 | 394 | #: app/templates/queue/my_queues.html:81 395 | msgid "Are you sure you want to delete?" 396 | msgstr "" 397 | 398 | #: app/templates/queue/my_queues.html:87 399 | msgid "This action cannot be undone." 400 | msgstr "" 401 | 402 | #: app/templates/queue/queue.html:63 403 | msgid "Next clearing" 404 | msgstr "" 405 | 406 | #: app/templates/queue/queue.html:69 407 | msgid "Next closing" 408 | msgstr "" 409 | 410 | #: app/templates/queue/queue.html:75 411 | msgid "Next opening" 412 | msgstr "" 413 | 414 | #: app/templates/queue/queue.html:104 415 | msgid "Leave" 416 | msgstr "" 417 | 418 | -------------------------------------------------------------------------------- /migrations/README: -------------------------------------------------------------------------------- 1 | Single-database configuration for Flask. 2 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | 18 | def get_engine(): 19 | try: 20 | # this works with Flask-SQLAlchemy<3 and Alchemical 21 | return current_app.extensions['migrate'].db.get_engine() 22 | except TypeError: 23 | # this works with Flask-SQLAlchemy>=3 24 | return current_app.extensions['migrate'].db.engine 25 | 26 | 27 | def get_engine_url(): 28 | try: 29 | return get_engine().url.render_as_string(hide_password=False).replace( 30 | '%', '%%') 31 | except AttributeError: 32 | return str(get_engine().url).replace('%', '%%') 33 | 34 | 35 | # add your model's MetaData object here 36 | # for 'autogenerate' support 37 | # from myapp import mymodel 38 | # target_metadata = mymodel.Base.metadata 39 | config.set_main_option('sqlalchemy.url', get_engine_url()) 40 | target_db = current_app.extensions['migrate'].db 41 | 42 | # other values from the config, defined by the needs of env.py, 43 | # can be acquired: 44 | # my_important_option = config.get_main_option("my_important_option") 45 | # ... etc. 46 | 47 | 48 | def get_metadata(): 49 | if hasattr(target_db, 'metadatas'): 50 | return target_db.metadatas[None] 51 | return target_db.metadata 52 | 53 | 54 | def run_migrations_offline(): 55 | """Run migrations in 'offline' mode. 56 | 57 | This configures the context with just a URL 58 | and not an Engine, though an Engine is acceptable 59 | here as well. By skipping the Engine creation 60 | we don't even need a DBAPI to be available. 61 | 62 | Calls to context.execute() here emit the given string to the 63 | script output. 64 | 65 | """ 66 | url = config.get_main_option("sqlalchemy.url") 67 | context.configure( 68 | url=url, target_metadata=get_metadata(), literal_binds=True 69 | ) 70 | 71 | with context.begin_transaction(): 72 | context.run_migrations() 73 | 74 | 75 | def run_migrations_online(): 76 | """Run migrations in 'online' mode. 77 | 78 | In this scenario we need to create an Engine 79 | and associate a connection with the context. 80 | 81 | """ 82 | 83 | # this callback is used to prevent an auto-migration from being generated 84 | # when there are no changes to the schema 85 | # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html 86 | def process_revision_directives(context, revision, directives): 87 | if getattr(config.cmd_opts, 'autogenerate', False): 88 | script = directives[0] 89 | if script.upgrade_ops.is_empty(): 90 | directives[:] = [] 91 | logger.info('No changes in schema detected.') 92 | 93 | connectable = get_engine() 94 | 95 | with connectable.connect() as connection: 96 | context.configure( 97 | connection=connection, 98 | target_metadata=get_metadata(), 99 | process_revision_directives=process_revision_directives, 100 | **current_app.extensions['migrate'].configure_args 101 | ) 102 | 103 | with context.begin_transaction(): 104 | context.run_migrations() 105 | 106 | 107 | if context.is_offline_mode(): 108 | run_migrations_offline() 109 | else: 110 | run_migrations_online() 111 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /migrations/versions/c03051ba358c_.py: -------------------------------------------------------------------------------- 1 | """empty message 2 | 3 | Revision ID: c03051ba358c 4 | Revises: 5 | Create Date: 2024-04-06 13:56:44.122445 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'c03051ba358c' 14 | down_revision = None 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('stats', 22 | sa.Column('name', sa.String(length=30), nullable=False), 23 | sa.Column('count', sa.Integer(), nullable=False), 24 | sa.PrimaryKeyConstraint('name') 25 | ) 26 | op.create_table('user', 27 | sa.Column('id', sa.Integer(), nullable=False), 28 | sa.Column('username', sa.String(length=64), nullable=True), 29 | sa.Column('email', sa.String(length=120), nullable=True), 30 | sa.Column('password_hash', sa.String(length=128), nullable=True), 31 | sa.Column('is_guest', sa.Boolean(), nullable=True), 32 | sa.Column('ip_address', sa.String(length=30), nullable=True), 33 | sa.Column('name_to_print', sa.String(length=30), nullable=True), 34 | sa.Column('is_confirmed', sa.Boolean(), nullable=False), 35 | sa.Column('confirmed_on', sa.DateTime(), nullable=True), 36 | sa.Column('is_admin', sa.Boolean(), nullable=False), 37 | sa.Column('like_given', sa.Boolean(), nullable=False), 38 | sa.PrimaryKeyConstraint('id') 39 | ) 40 | with op.batch_alter_table('user', schema=None) as batch_op: 41 | batch_op.create_index(batch_op.f('ix_user_email'), ['email'], unique=True) 42 | batch_op.create_index(batch_op.f('ix_user_username'), ['username'], unique=True) 43 | 44 | op.create_table('queue', 45 | sa.Column('id', sa.String(length=10), nullable=False), 46 | sa.Column('name', sa.String(length=100), nullable=False), 47 | sa.Column('last_index', sa.Integer(), nullable=False), 48 | sa.Column('is_open', sa.Boolean(), nullable=False), 49 | sa.Column('admin_id', sa.Integer(), nullable=False), 50 | sa.ForeignKeyConstraint(['admin_id'], ['user.id'], ), 51 | sa.PrimaryKeyConstraint('id') 52 | ) 53 | op.create_table('queue_task', 54 | sa.Column('id', sa.Integer(), nullable=False), 55 | sa.Column('queue_id', sa.String(length=10), nullable=False), 56 | sa.Column('action', sa.Enum('clear', 'close', 'open', name='taskenum'), nullable=False), 57 | sa.Column('execute_time', sa.DateTime(), nullable=False), 58 | sa.ForeignKeyConstraint(['queue_id'], ['queue.id'], ondelete='CASCADE'), 59 | sa.PrimaryKeyConstraint('id') 60 | ) 61 | with op.batch_alter_table('queue_task', schema=None) as batch_op: 62 | batch_op.create_index(batch_op.f('ix_queue_task_execute_time'), ['execute_time'], unique=False) 63 | 64 | op.create_table('user_queue', 65 | sa.Column('member_id', sa.Integer(), nullable=False), 66 | sa.Column('queue_id', sa.String(length=10), nullable=False), 67 | sa.Column('name_printed', sa.String(length=30), nullable=False), 68 | sa.Column('index_in_queue', sa.Integer(), nullable=False), 69 | sa.Column('arrive_time', sa.DateTime(), nullable=False), 70 | sa.Column('is_visible', sa.Boolean(), nullable=False), 71 | sa.ForeignKeyConstraint(['member_id'], ['user.id'], ondelete='CASCADE'), 72 | sa.ForeignKeyConstraint(['queue_id'], ['queue.id'], ondelete='CASCADE'), 73 | sa.PrimaryKeyConstraint('member_id', 'queue_id', 'index_in_queue') 74 | ) 75 | # ### end Alembic commands ### 76 | 77 | 78 | def downgrade(): 79 | # ### commands auto generated by Alembic - please adjust! ### 80 | op.drop_table('user_queue') 81 | with op.batch_alter_table('queue_task', schema=None) as batch_op: 82 | batch_op.drop_index(batch_op.f('ix_queue_task_execute_time')) 83 | 84 | op.drop_table('queue_task') 85 | op.drop_table('queue') 86 | with op.batch_alter_table('user', schema=None) as batch_op: 87 | batch_op.drop_index(batch_op.f('ix_user_username')) 88 | batch_op.drop_index(batch_op.f('ix_user_email')) 89 | 90 | op.drop_table('user') 91 | op.drop_table('stats') 92 | # ### end Alembic commands ### 93 | -------------------------------------------------------------------------------- /passenger_wsgi.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import os 4 | os.environ["PASSENGER_MAX_INSTANCES_PER_APP"] = "1" 5 | # os.environ['PASSENGER_MAX_POOL_SIZE'] = '1' 6 | 7 | INTERP = os.path.expanduser("/home/forva/FlaskPlayground/QueueSite/venv/bin/python") 8 | if sys.executable != INTERP: 9 | os.execl(INTERP, INTERP, *sys.argv) 10 | 11 | sys.path.append(os.getcwd()) 12 | 13 | from queue_app import application 14 | 15 | if __name__ == '__main__': 16 | application.run() 17 | -------------------------------------------------------------------------------- /queue_app.py: -------------------------------------------------------------------------------- 1 | from app import create_app, db 2 | from app.queue.models import Queue, UserQueue 3 | from app.auth.models import User 4 | 5 | application = create_app() 6 | 7 | if __name__ == "__main__": 8 | application.run('0.0.0.0') 9 | 10 | 11 | @application.shell_context_processor 12 | def make_shell_context(): 13 | create_app() 14 | return {'db': db, 'User': User, 'Queue': Queue, 'UserQueue': UserQueue} 15 | -------------------------------------------------------------------------------- /readme_resources/creating.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vaniog/QueueHere/1cfdd8fbf05f3ce03dca33f9984d16aa7863ebfe/readme_resources/creating.png -------------------------------------------------------------------------------- /readme_resources/joining.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vaniog/QueueHere/1cfdd8fbf05f3ce03dca33f9984d16aa7863ebfe/readme_resources/joining.png -------------------------------------------------------------------------------- /readme_resources/managing_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vaniog/QueueHere/1cfdd8fbf05f3ce03dca33f9984d16aa7863ebfe/readme_resources/managing_1.png -------------------------------------------------------------------------------- /readme_resources/managing_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vaniog/QueueHere/1cfdd8fbf05f3ce03dca33f9984d16aa7863ebfe/readme_resources/managing_2.png -------------------------------------------------------------------------------- /readme_resources/managing_3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vaniog/QueueHere/1cfdd8fbf05f3ce03dca33f9984d16aa7863ebfe/readme_resources/managing_3.png -------------------------------------------------------------------------------- /readme_resources/options.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vaniog/QueueHere/1cfdd8fbf05f3ce03dca33f9984d16aa7863ebfe/readme_resources/options.png -------------------------------------------------------------------------------- /readme_resources/queue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vaniog/QueueHere/1cfdd8fbf05f3ce03dca33f9984d16aa7863ebfe/readme_resources/queue.png -------------------------------------------------------------------------------- /readme_resources/sharing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vaniog/QueueHere/1cfdd8fbf05f3ce03dca33f9984d16aa7863ebfe/readme_resources/sharing.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | alembic==1.9.4 2 | attrs==22.2.0 3 | Babel==2.12.1 4 | blinker==1.6 5 | Bootstrap-Flask==2.2.0 6 | certifi==2022.12.7 7 | charset-normalizer==3.1.0 8 | click==8.1.3 9 | dnspython==2.3.0 10 | dominate==2.7.0 11 | email-validator==1.3.1 12 | exceptiongroup==1.1.1 13 | Flask==2.2.3 14 | flask-babel==3.1.0 15 | Flask-Login==0.6.2 16 | Flask-Mail==0.9.1 17 | Flask-Migrate==4.0.4 18 | Flask-Moment==1.0.5 19 | Flask-QRcode==3.1.0 20 | Flask-SQLAlchemy==3.0.3 21 | Flask-WTF==1.1.1 22 | greenlet==2.0.2 23 | gunicorn==20.1.0 24 | idna==3.4 25 | iniconfig==2.0.0 26 | itsdangerous==2.1.2 27 | Jinja2==3.1.2 28 | Mako==1.2.4 29 | MarkupSafe==2.1.2 30 | packaging==23.0 31 | Pillow==9.5.0 32 | pluggy==1.0.0 33 | PyMySQL==1.0.3 34 | pypng==0.20220715.0 35 | pytest==7.2.2 36 | python-dotenv==1.0.0 37 | pytz==2023.3 38 | PyYAML==6.0 39 | qrcode==7.4.2 40 | requests==2.28.2 41 | shortuuid==1.0.11 42 | SQLAlchemy==2.0.4 43 | tomli==2.0.1 44 | typing_extensions==4.5.0 45 | urllib3==1.26.15 46 | visitor==0.1.3 47 | Werkzeug==2.2.3 48 | WTForms==3.0.1 49 | -------------------------------------------------------------------------------- /tests.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from app import db, create_app 3 | from app.queue.models import Queue, UserQueue 4 | from app.auth.models import User 5 | from app.config import Config 6 | 7 | 8 | class UserModelCase(unittest.TestCase): 9 | def setUp(self): 10 | config_obj = Config() 11 | config_obj.SQLALCHEMY_DATABASE_URI = 'sqlite://' 12 | app = create_app(config_obj) 13 | 14 | db.session.remove() 15 | db.drop_all() 16 | db.create_all() 17 | 18 | def tearDown(self): 19 | db.session.remove() 20 | db.drop_all() 21 | 22 | def test_password_hashing(self): 23 | u = User(username='susan') 24 | u.set_password('cat') 25 | self.assertFalse(u.check_password('dog')) 26 | self.assertTrue(u.check_password('cat')) 27 | 28 | def test_avatar(self): 29 | u = User(username='john', email='john@example.com') 30 | self.assertEqual(u.avatar(128), ('https://www.gravatar.com/avatar/' 31 | 'd4c74594d841139328695756648b6bd6' 32 | '?d=identicon&s=128')) 33 | 34 | def test_queue(self): 35 | u1 = User(username='u1', email='u1@gmail.com') 36 | u2 = User(username='u2', email='u2@gmail.com') 37 | u3 = User(username='u3', email='u3@gmail.com') 38 | 39 | q1 = Queue(name="q") 40 | q2 = Queue(name="q") 41 | 42 | q1.add_member(u1, 'u1 printed') 43 | q1.add_member(u2, 'u2 printed') 44 | 45 | db.session.add_all([u1, u2, u3, q1, q2]) 46 | db.session.commit() 47 | 48 | self.assertEqual(2, len(q1.members)) 49 | self.assertTrue(q1.contains(u1)) 50 | self.assertFalse(q1.contains(u3)) 51 | 52 | q1.remove_member(u1) 53 | db.session.commit() 54 | 55 | self.assertEqual(1, len(q1.members)) 56 | self.assertFalse(q1.contains(u1)) 57 | 58 | 59 | if __name__ == '__main__': 60 | unittest.main(verbosity=2) 61 | --------------------------------------------------------------------------------