Browse Source

add server

Mert 2 months ago
parent
commit
b844c3c427

+ 6 - 0
server/README.md

@@ -0,0 +1,6 @@
+The 'server' folder contains 2 folders: 'web' and 'nginx'. Both contain the files for starting a docker container
+
+There is a docker-compose file that starts the 2 containers with the correct environment.
+
+The server is written in Python and uses Flask for a REST API and flask-sqlalchemy for DB (SQLlite)
+

+ 4 - 0
server/nginx/Dockerfile

@@ -0,0 +1,4 @@
+FROM nginx:1.23-alpine
+
+RUN rm /etc/nginx/conf.d/default.conf
+COPY nginx.conf /etc/nginx/conf.d

+ 19 - 0
server/nginx/nginx.conf

@@ -0,0 +1,19 @@
+upstream smarthubs {
+    server web:5000;
+}
+
+server {
+
+    # max upload size
+    client_max_body_size 10M;
+
+    listen 80;
+
+    location / {
+        proxy_pass http://smarthubs;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_set_header Host $host;
+        proxy_redirect off;
+    }
+
+}

+ 23 - 0
server/web/Dockerfile

@@ -0,0 +1,23 @@
+# pull official base image
+FROM python:3.10.7-slim-buster
+
+# set work directory
+WORKDIR /usr/src/app
+
+# set environment variables
+ENV PYTHONDONTWRITEBYTECODE 1
+ENV PYTHONUNBUFFERED 1
+
+# install system dependencies
+RUN apt-get update && apt-get install -y netcat
+
+# install dependencies
+RUN pip install --upgrade pip
+COPY ./requirements.txt /usr/src/app/requirements.txt
+RUN pip install -r requirements.txt
+
+# copy project
+COPY . /usr/src/app/
+
+RUN chmod u+x ./entrypoint.sh
+ENTRYPOINT ["./entrypoint.sh"]

+ 67 - 0
server/web/Dockerfile-prod

@@ -0,0 +1,67 @@
+###########
+# BUILDER #
+###########
+
+# pull official base image
+FROM python:3.10.7-slim-buster as builder
+
+# set work directory
+WORKDIR /usr/src/app
+
+# set environment variables
+ENV PYTHONDONTWRITEBYTECODE 1
+ENV PYTHONUNBUFFERED 1
+
+# install system dependencies
+RUN apt-get update && \
+    apt-get install -y --no-install-recommends gcc
+
+# lint
+RUN pip install --upgrade pip
+COPY . /usr/src/app/
+
+# install python dependencies
+COPY ./requirements.txt .
+RUN pip wheel --no-cache-dir --no-deps --wheel-dir /usr/src/app/wheels -r requirements.txt
+
+
+#########
+# FINAL #
+#########
+
+# pull official base image
+FROM python:3.10.7-slim-buster
+
+# create directory for the app user
+RUN mkdir -p /home/app
+
+# create the app user
+RUN addgroup --system app && adduser --system --group app
+
+# create the appropriate directories
+ENV HOME=/home/app
+ENV APP_HOME=/home/app/web
+RUN mkdir $APP_HOME
+WORKDIR $APP_HOME
+
+# install dependencies
+RUN apt-get update && apt-get install -y --no-install-recommends netcat
+COPY --from=builder /usr/src/app/wheels /wheels
+COPY --from=builder /usr/src/app/requirements.txt .
+RUN pip install --upgrade pip
+RUN pip install --no-cache /wheels/*
+
+# copy entrypoint-prod.sh
+COPY ./entrypoint.sh $APP_HOME
+
+# copy project
+COPY . $APP_HOME
+
+# chown all the files to the app user
+RUN chown -R app:app $APP_HOME
+
+# change to the app user
+USER app
+
+#RUN chmod u+x ./entrypoint.sh
+ENTRYPOINT ["/home/app/web/entrypoint.sh"]

+ 4 - 0
server/web/entrypoint.sh

@@ -0,0 +1,4 @@
+#!/bin/sh
+#python manage.py create_db
+python manage.py db upgrade
+exec "$@"

+ 22 - 0
server/web/manage.py

@@ -0,0 +1,22 @@
+from flask.cli import FlaskGroup
+from project import create_app
+from project.models import create_db, reset_db, migrate
+
+app = create_app()
+cli = FlaskGroup(app)
+
+
+@cli.command("create_db")
+def set_up_db():
+    with app.app_context():
+        create_db()
+
+
+@cli.command("reset_db")
+def resetdb():
+    with app.app_context():
+        reset_db()
+
+
+if __name__ == "__main__":
+    cli()

+ 1 - 0
server/web/migrations/README

@@ -0,0 +1 @@
+Single-database configuration for Flask.

+ 50 - 0
server/web/migrations/alembic.ini

@@ -0,0 +1,50 @@
+# A generic, single database configuration.
+
+[alembic]
+# template used to generate migration files
+# file_template = %%(rev)s_%%(slug)s
+
+# set to 'true' to run the environment during
+# the 'revision' command, regardless of autogenerate
+# revision_environment = false
+
+
+# Logging configuration
+[loggers]
+keys = root,sqlalchemy,alembic,flask_migrate
+
+[handlers]
+keys = console
+
+[formatters]
+keys = generic
+
+[logger_root]
+level = WARN
+handlers = console
+qualname =
+
+[logger_sqlalchemy]
+level = WARN
+handlers =
+qualname = sqlalchemy.engine
+
+[logger_alembic]
+level = INFO
+handlers =
+qualname = alembic
+
+[logger_flask_migrate]
+level = INFO
+handlers =
+qualname = flask_migrate
+
+[handler_console]
+class = StreamHandler
+args = (sys.stderr,)
+level = NOTSET
+formatter = generic
+
+[formatter_generic]
+format = %(levelname)-5.5s [%(name)s] %(message)s
+datefmt = %H:%M:%S

+ 110 - 0
server/web/migrations/env.py

@@ -0,0 +1,110 @@
+import logging
+from logging.config import fileConfig
+
+from flask import current_app
+
+from alembic import context
+
+# this is the Alembic Config object, which provides
+# access to the values within the .ini file in use.
+config = context.config
+
+# Interpret the config file for Python logging.
+# This line sets up loggers basically.
+fileConfig(config.config_file_name)
+logger = logging.getLogger('alembic.env')
+
+
+def get_engine():
+    try:
+        # this works with Flask-SQLAlchemy<3 and Alchemical
+        return current_app.extensions['migrate'].db.get_engine()
+    except TypeError:
+        # this works with Flask-SQLAlchemy>=3
+        return current_app.extensions['migrate'].db.engine
+
+
+def get_engine_url():
+    try:
+        return get_engine().url.render_as_string(hide_password=False).replace(
+            '%', '%%')
+    except AttributeError:
+        return str(get_engine().url).replace('%', '%%')
+
+
+# add your model's MetaData object here
+# for 'autogenerate' support
+# from myapp import mymodel
+# target_metadata = mymodel.Base.metadata
+config.set_main_option('sqlalchemy.url', get_engine_url())
+target_db = current_app.extensions['migrate'].db
+
+# other values from the config, defined by the needs of env.py,
+# can be acquired:
+# my_important_option = config.get_main_option("my_important_option")
+# ... etc.
+
+
+def get_metadata():
+    if hasattr(target_db, 'metadatas'):
+        return target_db.metadatas[None]
+    return target_db.metadata
+
+
+def run_migrations_offline():
+    """Run migrations in 'offline' mode.
+
+    This configures the context with just a URL
+    and not an Engine, though an Engine is acceptable
+    here as well.  By skipping the Engine creation
+    we don't even need a DBAPI to be available.
+
+    Calls to context.execute() here emit the given string to the
+    script output.
+
+    """
+    url = config.get_main_option("sqlalchemy.url")
+    context.configure(
+        url=url, target_metadata=get_metadata(), literal_binds=True
+    )
+
+    with context.begin_transaction():
+        context.run_migrations()
+
+
+def run_migrations_online():
+    """Run migrations in 'online' mode.
+
+    In this scenario we need to create an Engine
+    and associate a connection with the context.
+
+    """
+
+    # this callback is used to prevent an auto-migration from being generated
+    # when there are no changes to the schema
+    # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
+    def process_revision_directives(context, revision, directives):
+        if getattr(config.cmd_opts, 'autogenerate', False):
+            script = directives[0]
+            if script.upgrade_ops.is_empty():
+                directives[:] = []
+                logger.info('No changes in schema detected.')
+
+    connectable = get_engine()
+
+    with connectable.connect() as connection:
+        context.configure(
+            connection=connection,
+            target_metadata=get_metadata(),
+            process_revision_directives=process_revision_directives,
+            **current_app.extensions['migrate'].configure_args
+        )
+
+        with context.begin_transaction():
+            context.run_migrations()
+
+
+if context.is_offline_mode():
+    run_migrations_offline()
+else:
+    run_migrations_online()

+ 24 - 0
server/web/migrations/script.py.mako

@@ -0,0 +1,24 @@
+"""${message}
+
+Revision ID: ${up_revision}
+Revises: ${down_revision | comma,n}
+Create Date: ${create_date}
+
+"""
+from alembic import op
+import sqlalchemy as sa
+${imports if imports else ""}
+
+# revision identifiers, used by Alembic.
+revision = ${repr(up_revision)}
+down_revision = ${repr(down_revision)}
+branch_labels = ${repr(branch_labels)}
+depends_on = ${repr(depends_on)}
+
+
+def upgrade():
+    ${upgrades if upgrades else "pass"}
+
+
+def downgrade():
+    ${downgrades if downgrades else "pass"}

+ 32 - 0
server/web/migrations/versions/020773f0f9a1_.py

@@ -0,0 +1,32 @@
+"""empty message
+
+Revision ID: 020773f0f9a1
+Revises: d11bbd8ae6d8
+Create Date: 2024-05-27 15:22:45.044762
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '020773f0f9a1'
+down_revision = 'd11bbd8ae6d8'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    with op.batch_alter_table('player', schema=None) as batch_op:
+        batch_op.add_column(sa.Column('character_card', sa.Integer(), nullable=True))
+
+    # ### end Alembic commands ###
+
+
+def downgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    with op.batch_alter_table('player', schema=None) as batch_op:
+        batch_op.drop_column('character_card')
+
+    # ### end Alembic commands ###

+ 35 - 0
server/web/migrations/versions/d11bbd8ae6d8_geo.py

@@ -0,0 +1,35 @@
+"""geo
+
+Revision ID: d11bbd8ae6d8
+Revises: 
+Create Date: 2023-02-27 17:29:51.928140
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = 'd11bbd8ae6d8'
+down_revision = None
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+
+    op.add_column('object', sa.Column('longitude', sa.String(length=200), nullable=True))
+    op.add_column('object', sa.Column('latitude', sa.String(length=200), nullable=True))
+    op.add_column('object', sa.Column('object_name', sa.String(length=200), nullable=True))
+
+    # ### end Alembic commands ###
+
+
+def downgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.drop_column('object', 'object_name')
+    op.drop_column('object', 'latitude')
+    op.drop_column('object', 'longitude')
+
+    # ### end Alembic commands ###

+ 84 - 0
server/web/migrations/versions/d1be967e706a_.py

@@ -0,0 +1,84 @@
+"""empty message
+
+Revision ID: d1be967e706a
+Revises: 020773f0f9a1
+Create Date: 2024-08-27 11:37:10.481634
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = 'd1be967e706a'
+down_revision = '020773f0f9a1'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.create_table('action_card',
+    sa.Column('id', sa.Integer(), nullable=False),
+    sa.Column('card_id', sa.Integer(), nullable=True),
+    sa.Column('action', sa.String(length=500), nullable=True),
+    sa.Column('img_path', sa.String(length=300), nullable=False),
+    sa.PrimaryKeyConstraint('id'),
+    sa.UniqueConstraint('card_id'),
+    sa.UniqueConstraint('img_path')
+    )
+    op.create_table('character_card',
+    sa.Column('id', sa.Integer(), nullable=False),
+    sa.Column('card_id', sa.Integer(), nullable=True),
+    sa.Column('name', sa.String(length=100), nullable=True),
+    sa.Column('age', sa.Integer(), nullable=True),
+    sa.Column('role', sa.String(length=100), nullable=True),
+    sa.Column('interest', sa.String(length=300), nullable=True),
+    sa.Column('quote', sa.String(length=300), nullable=True),
+    sa.Column('img_path', sa.String(length=300), nullable=False),
+    sa.PrimaryKeyConstraint('id'),
+    sa.UniqueConstraint('card_id'),
+    sa.UniqueConstraint('img_path')
+    )
+    op.create_table('goal_card',
+    sa.Column('id', sa.Integer(), nullable=False),
+    sa.Column('card_id', sa.Integer(), nullable=True),
+    sa.Column('goal', sa.String(length=500), nullable=True),
+    sa.Column('img_path', sa.String(length=300), nullable=False),
+    sa.PrimaryKeyConstraint('id'),
+    sa.UniqueConstraint('card_id'),
+    sa.UniqueConstraint('img_path')
+    )
+    op.create_table('PlayerActionCard',
+    sa.Column('player', sa.Integer(), nullable=False),
+    sa.Column('action_card', sa.Integer(), nullable=False),
+    sa.ForeignKeyConstraint(['action_card'], ['action_card.card_id'], ),
+    sa.ForeignKeyConstraint(['player'], ['player.id'], ),
+    sa.PrimaryKeyConstraint('player', 'action_card')
+    )
+    with op.batch_alter_table('player', schema=None) as batch_op:
+        batch_op.add_column(sa.Column('goal_card_id', sa.Integer(), nullable=True))
+        batch_op.add_column(sa.Column('character_card_id', sa.Integer(), nullable=True))
+        batch_op.create_foreign_key(None, 'goal_card', ['goal_card_id'], ['card_id'])
+        batch_op.create_foreign_key(None, 'character_card', ['character_card_id'], ['card_id'])
+        batch_op.drop_column('goal_card')
+        batch_op.drop_column('character_card')
+
+    # ### end Alembic commands ###
+
+
+def downgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    with op.batch_alter_table('player', schema=None) as batch_op:
+        batch_op.add_column(sa.Column('character_card', sa.INTEGER(), autoincrement=False, nullable=True))
+        batch_op.add_column(sa.Column('goal_card', sa.INTEGER(), autoincrement=False, nullable=True))
+        batch_op.drop_constraint(None, type_='foreignkey')
+        batch_op.drop_constraint(None, type_='foreignkey')
+        batch_op.drop_column('character_card_id')
+        batch_op.drop_column('goal_card_id')
+
+    op.drop_table('PlayerActionCard')
+    op.drop_table('goal_card')
+    op.drop_table('character_card')
+    op.drop_table('action_card')
+    # ### end Alembic commands ###

+ 76 - 0
server/web/project/__init__.py

@@ -0,0 +1,76 @@
+from flask import Flask, render_template, url_for
+from flask_sqlalchemy import SQLAlchemy
+from flask_bcrypt import Bcrypt
+from project.models import db, Game, Player, Object, Image, User, get_all_games, get_all_images, migrate, get_all_action_cards, get_all_char_cards, get_all_goal_cards
+from project.forms import PlayerPointsForm
+from project.routes import api, auth, singleplayer, limiter
+from PIL import Image as PImage
+import os
+from project.routes import login_manager
+from flask_login import login_required
+from base64 import b64encode
+from io import BytesIO
+
+
+def create_app(testing=False):
+    app = Flask(__name__)
+
+    app.config.from_object("project.config.Config")
+
+    db.init_app(app)
+    migrate.init_app(app, db)
+    login_manager.init_app(app)
+    login_manager.login_view = 'auth.login'  # so that all endpoints with @login_required are redirected to login if not authenticated
+    limiter.init_app(app)
+
+    app.register_blueprint(api, url_prefix="/api")
+    app.register_blueprint(singleplayer, url_prefix="/singleplayer")
+    app.register_blueprint(auth)
+
+    @app.route("/", methods=['GET'])
+    @app.route("/index", methods=['GET'])
+    @login_required
+    def index():
+        # form = PlayerPointsForm()
+
+        data = get_all_games()
+
+        game_ids = []
+        for row in data:
+            if row.Game.id not in game_ids:
+                game_ids.append(row.Game.id)
+
+        imgs = get_all_images()
+
+        images = {}
+
+        for img in imgs:
+            if f"{img.game_id}" not in images:
+                images[f"{img.game_id}"] = []
+
+            image = PImage.open(img.img_path)
+            image.thumbnail((30, 30)) #compress the image for viewing in table
+            image_io = BytesIO()
+            image.save(image_io, 'PNG')
+            images[f"{img.game_id}"].append('data:image/png;base64,' + b64encode(image_io.getvalue()).decode('ascii'))
+
+        action_cards = get_all_action_cards()
+        char_cards = get_all_char_cards()
+        goal_cards = get_all_goal_cards()
+
+        return render_template('index.html', title='SmartHubs', data=data, images=images, games=game_ids,
+                               action_cards=action_cards, char_cards=char_cards, goal_cards=goal_cards)
+
+    return app
+
+
+if __name__ == '__main__':
+    app = create_app()
+    app.run(debug=True)
+
+# sched = BackgroundScheduler(daemon=True)
+# sched.add_job(routes.algorithm,'interval',seconds = 60)
+# sched.init_app(app)
+# sched.start()
+
+# atexit.register(lambda: sched.shutdown())

+ 10 - 0
server/web/project/config.py

@@ -0,0 +1,10 @@
+import os
+
+basedir = os.path.abspath(os.path.dirname(__file__))
+
+
+class Config(object):
+    SQLALCHEMY_DATABASE_URI = os.getenv("DATABASE_URL", "sqlite://")
+    SQLALCHEMY_TRACK_MODIFICATIONS = False
+    SECRET_KEY = os.getenv('SECRET_KEY')
+    MEDIA_FOLDER = f"{os.getenv('APP_FOLDER')}/project/images"

+ 24 - 0
server/web/project/forms.py

@@ -0,0 +1,24 @@
+from flask_wtf import FlaskForm
+from wtforms import StringField, PasswordField, SubmitField, BooleanField, IntegerField, DateTimeField, SelectField
+from wtforms.validators import DataRequired, Length, EqualTo, Email, ValidationError
+import datetime
+
+
+class PlayerPointsForm(FlaskForm):
+    # total = StringField("TotalPoints", validators = [DataRequired(), Length(min=2, max=20)])
+
+    player1 = StringField("Player 1", validators=[DataRequired(), Length(min=0, max=20)])
+    player2 = StringField("Player 2", validators=[DataRequired(), Length(min=0, max=20)])
+    player3 = StringField("Player 3", validators=[DataRequired(), Length(min=0, max=20)])
+    player4 = StringField("Player 4", validators=[DataRequired(), Length(min=0, max=20)])
+    player5 = StringField("Player 5", validators=[DataRequired(), Length(min=0, max=20)])
+
+    points_submit = SubmitField("Add Points")
+
+    def validate_points(self, rfid_tag):
+        return True
+
+
+class LoginForm(FlaskForm):
+    username = StringField("username", validators=[DataRequired()])
+    password = PasswordField("password", validators=[DataRequired()])

+ 202 - 0
server/web/project/models.py

@@ -0,0 +1,202 @@
+from datetime import datetime
+from flask_sqlalchemy import SQLAlchemy
+from sqlalchemy import and_
+from sqlalchemy_utils import EncryptedType
+from flask_login import UserMixin
+from werkzeug.security import check_password_hash, generate_password_hash
+from project.config import Config
+from flask_migrate import Migrate
+
+db = SQLAlchemy()
+migrate = Migrate()
+
+
+def create_db():
+    db.create_all()
+    empty = db.session.query(User).first() is None
+    if empty:
+        db.session.add(User(
+            username="game_master",
+            password="accessgames1040"
+        ))
+        db.session.commit()
+
+
+def reset_db():
+    try:
+        db.drop_all()
+        create_db()
+    except Exception as e:
+        print(e)
+
+
+def get_all_games():
+    return db.session.query(Game, Player, Object).join(Player).join(Object, and_(Player.game_id == Object.game_id,
+                                                                                 Player.player_number == Object.player_number),
+                                                                    isouter=True).order_by(Game.id.desc(),
+                                                                                           Player.player_number.asc()).all()
+
+
+def get_all_images():
+    return db.session.query(Image).order_by(Image.game_id.desc()).all()
+
+
+def get_game_images(id):
+    return db.session.query(Image).filter(Image.game_id == id).all()
+
+
+def get_all_action_cards():
+    return db.session.query(ActionCard).all()
+
+
+def get_all_char_cards():
+    return db.session.query(CharacterCard).all()
+
+
+def get_all_goal_cards():
+    return db.session.query(GoalCard).all()
+
+
+# singleplayer table, just for singleplayer games, saves game id and points for 5 players
+class SingleplayerGame(db.Model):
+    id = db.Column(db.Integer, primary_key=True)
+    player_1_points = db.Column(db.Integer, default=0, nullable=False)
+    player_2_points = db.Column(db.Integer, default=0, nullable=False)
+    player_3_points = db.Column(db.Integer, default=0, nullable=False)
+    player_4_points = db.Column(db.Integer, default=0, nullable=False)
+    player_5_points = db.Column(db.Integer, default=0, nullable=False)
+
+
+# for now i removed game_instance since sqlite does not support sequences
+
+PlayerActionCard = db.Table('PlayerActionCard',
+
+                            db.Column("player",
+                                      db.Integer,
+                                      db.ForeignKey('player.id'),
+                                      primary_key=True),
+
+                            db.Column("action_card",
+                                      db.Integer,
+                                      db.ForeignKey('action_card.card_id'),
+                                      primary_key=True)
+                            )
+
+
+class Game(db.Model):
+    __tablename__ = "game"
+    id = db.Column(db.Integer, primary_key=True)
+    # game_instance = db.Column(db.Integer, nullable=False, unique=True, )
+    number_of_players = db.Column(db.Integer, nullable=False)
+    location = db.Column(db.String(200), nullable=False)
+    # current_player = db.Column(db.Integer, default=1, nullable=False)
+    closed = db.Column(db.Boolean, default=False, nullable=False)
+    players = db.relationship("Player", back_populates="game", cascade="all, delete", passive_deletes=True)
+    objects = db.relationship("Object", back_populates="game", cascade="all, delete", passive_deletes=True, )
+    images = db.relationship("Image", back_populates="game", cascade="all, delete", passive_deletes=True, )
+
+
+def turn_default(context):
+    return context.get_current_parameters()["player_number"] == 1
+
+
+class CharacterCard(db.Model):
+    __tablename__ = "character_card"
+    id = db.Column(db.Integer, primary_key=True)
+    card_id = db.Column(db.Integer, unique=True)
+    name = db.Column(db.String(100))
+    age = db.Column(db.Integer)
+    role = db.Column(db.String(100))
+    interest = db.Column(db.String(300))
+    quote = db.Column(db.String(300))
+    img_path = db.Column(db.String(300), nullable=False, unique=True)
+    players = db.relationship("Player", back_populates="character_card")
+
+
+class GoalCard(db.Model):
+    __tablename__ = "goal_card"
+    id = db.Column(db.Integer, primary_key=True)
+    card_id = db.Column(db.Integer, unique=True)
+    goal = db.Column(db.String(500))
+    img_path = db.Column(db.String(300), nullable=False, unique=True)
+    players = db.relationship("Player", back_populates="goal_card")
+
+
+class ActionCard(db.Model):
+    __tablename__ = "action_card"
+    id = db.Column(db.Integer, primary_key=True)
+    card_id = db.Column(db.Integer, unique=True)
+    action = db.Column(db.String(500))
+    img_path = db.Column(db.String(300), nullable=False, unique=True)
+
+
+# player_number : 1 - n (n = number_of_players from game)
+class Player(db.Model):
+    __tablename__ = "player"
+    id = db.Column(db.Integer, primary_key=True)
+    # game_instance = db.Column(db.Integer, db.ForeignKey(Game.game_instance), nullable=False)
+    game_id = db.Column(db.Integer, db.ForeignKey(Game.id, ondelete="CASCADE"), nullable=False)
+    player_number = db.Column(db.Integer, nullable=False)
+    is_taken = db.Column(db.Boolean, default=False, nullable=False)
+    # is_player_turn = db.Column(db.Boolean, default=turn_default, nullable=False)
+    points = db.Column(db.Integer, default=0, nullable=False)
+    goal_card_id = db.Column(db.Integer, db.ForeignKey(GoalCard.card_id), nullable=True)
+    character_card_id = db.Column(db.Integer, db.ForeignKey(CharacterCard.card_id), nullable=True)
+    game = db.relationship(Game, back_populates="players")
+    character_card = db.relationship('CharacterCard', back_populates="players")
+    goal_card = db.relationship('GoalCard', back_populates="players")
+    drawn_action_cards = db.relationship('ActionCard',
+                                         secondary=PlayerActionCard,
+                                         lazy=True,
+                                         backref=db.backref('players', lazy=True))
+
+
+# stores the objects which are in play with affiliated game and player ids
+class Object(db.Model):
+    __tablename__ = "object"
+    id = db.Column(db.Integer, primary_key=True)
+    # game_instance = db.Column(db.Integer, db.ForeignKey(Game.game_instance), nullable=False)
+    game_id = db.Column(db.Integer, db.ForeignKey(Game.id, ondelete="CASCADE"), nullable=False)
+    player_number = db.Column(db.Integer, nullable=False)
+    object_type = db.Column(db.Integer, nullable=False)
+    object_points = db.Column(db.Integer, nullable=False)
+    longitude = db.Column(db.String(200), nullable=True)
+    latitude = db.Column(db.String(200), nullable=True)
+    object_name = db.Column(db.String(200), nullable=True)
+    game = db.relationship(Game, back_populates="objects")
+    __table_args__ = (db.UniqueConstraint('game_id', 'object_type'),)
+
+    def to_json(self):
+        return {
+            'owner': self.player_number,
+            'latitude': self.latitude,
+            'longitude': self.longitude,
+            'object_name': self.object_name
+        }
+
+
+# in img_path save image to path: "server/images/<game_id>_<img_number>.png"
+class Image(db.Model):
+    __tablename__ = "image"
+    id = db.Column(db.Integer, primary_key=True)
+    # game_instance = db.Column(db.Integer, db.ForeignKey(Game.game_instance))
+    game_id = db.Column(db.Integer, db.ForeignKey(Game.id, ondelete="CASCADE"))
+    img_path = db.Column(db.String(300), nullable=False, unique=True)
+    game = db.relationship(Game, back_populates="images")
+
+
+class User(db.Model, UserMixin):
+    __tablename__ = "user"
+    id = db.Column(db.Integer, primary_key=True)
+    username = db.Column(EncryptedType(db.String(200), key=Config.SECRET_KEY), nullable=False)
+    password = db.Column(EncryptedType(db.String(200), key=Config.SECRET_KEY), nullable=False)
+
+    def __init__(self, username, password):
+        self.username = username
+        self.password = generate_password_hash(password)
+
+    def __repr__(self):
+        return f'<User {self.username}>'
+
+    def verify_password(self, pwd):
+        return check_password_hash(self.password, pwd)

File diff suppressed because it is too large
+ 1282 - 0
server/web/project/routes.py


+ 150 - 0
server/web/project/static/css/style.css

@@ -0,0 +1,150 @@
+/*
+table {
+    display: flex;
+    flex-flow: column;
+    width: 100%;
+}
+
+thead {
+    flex: 0 0 auto;
+}
+
+tbody {
+    flex: 1 1 auto;
+    display: block;
+    overflow-y: auto;
+    overflow-x: hidden;
+}
+
+tr {
+    width: 100%;
+    display: table;
+    table-layout: fixed;
+}
+*/
+
+.container {
+    display: flex;
+    flex-direction: column;
+    align-items: flex-start;
+    gap: 20px;
+}
+
+th {
+    background: white;
+    position: sticky;
+    top: 0; /* Don't forget this, required for the stickiness */
+}
+
+
+th, td {
+    border-inline: 1px solid black; /*Add this*/
+}
+
+table {
+    border-collapse: collapse;
+    text-align: center;
+}
+
+.title {
+    display: flex;
+    flex-direction: row;
+    justify-content: space-between;
+    flex-wrap: wrap;
+    align-items: center;
+    width: 100%;
+}
+
+/*
+.logout-btn{
+    align-self: center;
+}
+*/
+
+/*.btn {
+    margin: 20px;
+}
+
+#table {
+    margin: 0 20px 0 20px;
+}*/
+
+/*#table-img {
+    margin: 0 20px 0 20px;
+}*/
+
+.title h1 {
+    color: #5a74b7;
+    margin-top: 20px;
+    margin-bottom: 20px;
+    font-size: 63px;
+}
+
+#table {
+    display: flex;
+    align-items: flex-start;
+    flex-direction: column;
+}
+
+#table-img {
+    display: flex;
+    align-items: flex-start;
+    flex-direction: column;
+}
+
+#table table {
+    overflow-y: auto;
+    max-height: 400px;
+    display: block;
+    width: fit-content;
+}
+
+#table-img table {
+    overflow-y: auto;
+    max-height: 300px;
+    display: block;
+    width: fit-content;
+}
+
+/*.alert {
+    margin: 10px 0 10px 0;
+}*/
+
+.table-img {
+    height: 30px;
+}
+
+#response-msg {
+    color: red;
+}
+
+#loading-field {
+    display: none
+}
+
+.loader {
+    margin-inline: 5px;
+    margin-bottom: -8px;
+    width: 25px;
+    height: 25px;
+    border: 5px solid #ffffff;
+    border-bottom-color: transparent;
+    border-radius: 50%;
+    display: inline-block;
+    box-sizing: border-box;
+    animation: rotation 1s linear infinite;
+}
+
+.row-align{
+    display: flex;
+    flex-direction: row;
+}
+
+@keyframes rotation {
+    0% {
+        transform: rotate(0deg);
+    }
+    100% {
+        transform: rotate(360deg);
+    }
+}

File diff suppressed because it is too large
+ 3 - 0
server/web/project/static/js/FileSaver.min.js


+ 67 - 0
server/web/project/static/js/index.js

@@ -0,0 +1,67 @@
+async function download() {
+    let id = document.getElementById('select-game').value
+    if (id.length === 0){
+        return
+    }
+
+    document.getElementById('loading-field').style.display = 'inline-block'
+
+
+    let response = await $.get(`/api/get_images/${id}`);
+    let images = response.data;
+
+    document.getElementById('loading-field').style.display = 'none'
+
+    let zip = new JSZip();
+
+    // Add csv file
+    let csv_content = html_to_csv(id);
+    zip.file("game.csv", csv_content);
+
+    // Generate a directory within the Zip file structure
+    let img = zip.folder("images");
+
+    // Add a file to the directory, in this case an image with data URI as contents
+    for (let [key, game] of Object.entries(images)) {
+        for (let sub_key in game) {
+            let url = game[sub_key]
+            let idx = url.indexOf('base64,') + 'base64,'.length; // or = 28 if you're sure about the prefix
+            let content = game[sub_key].substring(idx);
+            img.file(`Game-${key}_Image-${parseInt(sub_key) + 1}.png`, content, {base64: true});
+        }
+    }
+
+    // Generate the zip file asynchronously
+    zip.generateAsync({type: "blob"})
+        .then(function (content) {
+            // Force down of the Zip file
+            saveAs(content, `Game_${id}.zip`);
+        });
+}
+
+/**
+ * EXPORT FUNCTION
+ */
+
+function html_to_csv(id) {
+    let data = [];
+    let rows = document.querySelectorAll("#table tr");
+
+    for (let i = 0; i < rows.length; i++) {
+        let row = [], cols = rows[i].querySelectorAll("td, th");
+
+        //only select rows from selected game id or if its the header row
+        if (cols[0].innerText !== id && i !== 0){
+            continue
+        }
+
+        for (let j = 0; j < cols.length; j++) {
+            row.push(cols[j].innerText);
+        }
+
+        data.push(row.join(";"));
+    }
+
+    let csv = data.join("\n");
+    return csv
+}

File diff suppressed because it is too large
+ 2 - 0
server/web/project/static/js/jquery-3.6.0.min.js


File diff suppressed because it is too large
+ 13 - 0
server/web/project/static/js/jszip.min.js


+ 605 - 0
server/web/project/templates/index.html

@@ -0,0 +1,605 @@
+<!doctype html>
+
+<html>
+
+<head>
+    <meta charset="utf-8">
+    <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
+    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.0.0/dist/css/bootstrap.min.css"
+          integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm" crossorigin="anonymous">
+    <script src="https://code.jquery.com/jquery-3.2.1.slim.min.js"
+            integrity="sha384-KJ3o2DKtIkvYIK3UENzmM7KCkRr/rE9/Qpg6aAZGJwFDMVNA/GpGFF93hXpG5KkN"
+            crossorigin="anonymous"></script>
+    <script src="https://cdn.jsdelivr.net/npm/popper.js@1.12.9/dist/umd/popper.min.js"
+            integrity="sha384-ApNbgh9B+Y1QKtv3Rn7W3mgPxhU9K/ScQsAP7hUibX39j7fakFPskvXusvfa0b4Q"
+            crossorigin="anonymous"></script>
+    <script src="https://cdn.jsdelivr.net/npm/bootstrap@4.0.0/dist/js/bootstrap.min.js"
+            integrity="sha384-JZR6Spejh4U02d8jOt6vLEHfe/JQGiRRSQQxSfFWpi1MquVdAyjUar5+76PVCmYl"
+            crossorigin="anonymous"></script>
+    <script type="text/javascript" src="{{ url_for('static', filename='js/index.js') }}"></script>
+    <script type="text/javascript" src="{{ url_for('static', filename='js/jszip.min.js') }}"></script>
+    <script type="text/javascript" src="{{ url_for('static', filename='js/FileSaver.min.js') }}"></script>
+    <script type="text/javascript" src="{{ url_for('static', filename='js/jquery-3.6.0.min.js') }}"></script>
+
+    <title>StreetForum Design Game</title>
+</head>
+
+<body>
+
+<div class="container">
+
+    <div class="title">
+        <h1>
+            StreetForum Design Game
+        </h1>
+        <div class="logout-btn">
+            <form id="logout" action="/logout">
+                <input class="btn btn-primary" type="submit" value="Log out"/>
+            </form>
+        </div>
+    </div>
+
+
+    <div class="download">
+        <div class="input-group">
+            <div class="input-group-prepend">
+                <span class="input-group-text">Select Game for Download:</span>
+            </div>
+            <select class="custom-select" id="select-game">
+                {% for id in games %}
+                    <option value="{{ id }}">{{ id }}</option>
+                {% endfor %}
+            </select>
+            <div class="input-group-append">
+                <button class="btn btn-primary" type="button" onclick="download()">Download (images +
+                    .csv)
+                    <a id="loading-field"><span class="loader"></span></a>
+                </button>
+            </div>
+        </div>
+    </div>
+
+    <div id="table">
+        <h2> Game Table </h2>
+        <table class="table {#table-striped#} table-responsive table-hover">
+            <thead class="table-header">
+            <tr>
+                <th>game number</th>
+                <!--<th>game images</th>-->
+                <th>number of players</th>
+                <th>location</th>
+                <th>closed</th>
+                <th>player number</th>
+                <th>claimed</th>
+                <th>points</th>
+                <th>goal card</th>
+                <th>character card</th>
+                <th>action card</th>
+                <th>object ID</th>
+                <th>object name</th>
+                <th>object points</th>
+            </tr>
+            </thead>
+            <tbody>
+            {% for row in data %}
+                <tr>
+                    <td>{{ row.Game.id }}</td>
+                    {#
+                {% set id = row.Game.id %}
+                {% if images[id | string] is defined %}
+                    <td>
+                    {% for img in images[id | string] %}
+                        <img class="table-img img-responsive" src="{{ img }}">
+                    {% endfor %}
+                    </td>
+                {% else %}
+                    <td>
+                        -
+                    </td>
+                {% endif %}
+                #}
+                    <td>{{ row.Game.number_of_players }}</td>
+                    <td>{{ row.Game.location }}</td>
+                    <td>{{ row.Game.closed }}</td>
+                    <td>{{ row.Player.player_number }}</td>
+                    <td>{{ row.Player.is_taken }}</td>
+                    <td>{{ row.Player.points }}</td>
+                    <td>{{ row.Player.goal_card_id }}</td>
+                    <td>{{ row.Player.character_card_id }}</td>
+                    <td>
+                        {% for ac in row.Player.drawn_action_cards %}
+                            {{ ac.card_id }},
+                        {% endfor %}
+                    </td>
+                    <td>{{ row.Object.object_type }}</td>
+                    <td>{{ row.Object.object_name }}</td>
+                    <td>{{ row.Object.object_points }}</td>
+                </tr>
+            {% endfor %}
+            </tbody>
+        </table>
+    </div>
+
+
+    <div id="table-img">
+        <h2> Image Table </h2>
+        <table class="table {#table-striped#} table-responsive table-hover">
+            <thead class="table-header">
+            <tr>
+                <th>game number</th>
+                <th>game images</th>
+            </tr>
+            </thead>
+            <tbody>
+            {% for key, row in images.items() %}
+                <tr>
+                    <td>{{ key }}</td>
+                    <td>
+                        {% for img in row %}
+                            <img class="table-img img-responsive" src="{{ img }}">
+                        {% endfor %}
+                    </td>
+                </tr>
+            {% endfor %}
+            </tbody>
+        </table>
+    </div>
+
+    <div id="action-card-table">
+        <div class="row-align"><h2> Action Cards </h2>
+            <!-- Button trigger modal -->
+            <button style="margin-left: 10px" type="button" class="btn btn-primary" data-toggle="modal"
+                    data-target="#addActionCard">
+                Add Card
+            </button>
+        </div>
+        <table class="table {#table-striped#} table-responsive table-hover">
+            <thead class="table-header">
+            <tr>
+                <th>Card ID</th>
+                <th>Action</th>
+                <th>Image</th>
+            </tr>
+            </thead>
+            <tbody>
+            {% for card in action_cards %}
+                <tr>
+                    <td>{{ card.card_id }}</td>
+                    <td>{{ card.action }}</td>
+                    <td>{% set filename = card.img_path.split('/')[-1] %}
+                        <a target="_blank" rel="noopener noreferrer"
+                           href="/api/get_card_image/{{ filename }}">{{ filename }}</a>
+                    </td>
+                    <td>
+                        <button type="button" class="btn btn-danger"
+                                onclick="delete_card('action', {{ card.card_id }})">Delete
+                        </button>
+                    </td>
+                </tr>
+            {% endfor %}
+            </tbody>
+        </table>
+    </div>
+
+    <!-- Modal -->
+    <div class="modal fade" id="addActionCard" tabindex="-1" role="dialog"
+         aria-labelledby="addActionCardTitle"
+         aria-hidden="true">
+        <div class="modal-dialog modal-dialog-centered" role="document">
+            <div class="modal-content">
+                <div class="modal-header">
+                    <h5 class="modal-title" id="addActionCardTitle">Add an Action Card</h5>
+                    <button type="button" class="close" data-dismiss="modal" aria-label="Close">
+                        <span aria-hidden="true">&times;</span>
+                    </button>
+                </div>
+                <form id="submit-action-card">
+                    <div class="modal-body">
+
+                        <div class="form-group">
+                            <label for="cardid_action" class="col-form-label">Card ID: </label>
+                            <input type="number" class="form-control" id="cardid_action" required>
+
+                            <label for="action_action" class="col-form-label">Action: </label>
+                            <input type="text" class="form-control" id="action_action" required>
+
+                            <label for="img_action" class="col-form-label">Image: </label>
+                            <input type="file" accept="image/png" class="form-control" id="img_action" required>
+                        </div>
+
+                        <div id="response-msg-action">
+
+                        </div>
+                    </div>
+                    <div class="modal-footer">
+                        <button type="button" class="btn btn-secondary" data-dismiss="modal">Cancel</button>
+                        <button type="submit" class="btn btn-primary">Submit & Add</button>
+                    </div>
+                </form>
+            </div>
+        </div>
+    </div>
+
+    <div id="char-card-table">
+        <div class="row-align"><h2> Character Cards </h2>
+            <button style="margin-left: 10px" type="button" class="btn btn-primary" data-toggle="modal"
+                    data-target="#addCharCard">
+                Add Card
+            </button>
+        </div>
+        <table class="table {#table-striped#} table-responsive table-hover">
+            <thead class="table-header">
+            <tr>
+                <th>Card ID</th>
+                <th>Name</th>
+                <th>Age</th>
+                <th>Role</th>
+                <th>Interest</th>
+                <th>Quote</th>
+                <th>Image</th>
+            </tr>
+            </thead>
+            <tbody>
+            {% for card in char_cards %}
+                <tr>
+                    <td>{{ card.card_id }}</td>
+                    <td>{{ card.name }}</td>
+                    <td>{{ card.age }}</td>
+                    <td>{{ card.role }}</td>
+                    <td>{{ card.interest }}</td>
+                    <td>{{ card.quote }}</td>
+                    <td>{% set filename = card.img_path.split('/')[-1] %}
+                        <a target="_blank" rel="noopener noreferrer"
+                           href="/api/get_card_image/{{ filename }}">{{ filename }}</a>
+                    </td>
+                    <td>
+                        <button type="button" class="btn btn-danger" onclick="delete_card('char', {{ card.card_id }})">
+                            Delete
+                        </button>
+                    </td>
+                </tr>
+            {% endfor %}
+            </tbody>
+        </table>
+    </div>
+
+    <!-- Modal -->
+    <div class="modal fade" id="addCharCard" tabindex="-1" role="dialog"
+         aria-labelledby="addCharCardTitle"
+         aria-hidden="true">
+        <div class="modal-dialog modal-dialog-centered" role="document">
+            <div class="modal-content">
+                <div class="modal-header">
+                    <h5 class="modal-title" id="addCharCardTitle">Add a Character Card</h5>
+                    <button type="button" class="close" data-dismiss="modal" aria-label="Close">
+                        <span aria-hidden="true">&times;</span>
+                    </button>
+                </div>
+                <form id="submit-char-card">
+                    <div class="modal-body">
+
+                        <div class="form-group">
+                            <label for="cardid_char" class="col-form-label">Card ID: </label>
+                            <input type="number" class="form-control" id="cardid_char" required>
+
+                            <label for="name_char" class="col-form-label">Name: </label>
+                            <input type="text" class="form-control" id="name_char" required>
+
+                            <label for="age_char" class="col-form-label">Age: </label>
+                            <input type="number" accept="image/png" class="form-control" id="age_char" required>
+
+                            <label for="role_char" class="col-form-label">Role: </label>
+                            <input type="text" class="form-control" id="role_char" required>
+
+                            <label for="interest_char" class="col-form-label">Interest: </label>
+                            <input type="text" class="form-control" id="interest_char" required>
+
+                            <label for="quote_char" class="col-form-label">Quote: </label>
+                            <input type="text" class="form-control" id="quote_char" required>
+
+                            <label for="img_char" class="col-form-label">Image: </label>
+                            <input type="file" accept="image/png" class="form-control" id="img_char" required>
+                        </div>
+
+                        <div id="response-msg-char">
+
+                        </div>
+                    </div>
+                    <div class="modal-footer">
+                        <button type="button" class="btn btn-secondary" data-dismiss="modal">Cancel</button>
+                        <button type="submit" class="btn btn-primary">Submit & Add</button>
+                    </div>
+                </form>
+            </div>
+        </div>
+    </div>
+
+    <div id="goal-card-table">
+        <div class="row-align"><h2> Goal Cards </h2>
+            <button style="margin-left: 10px" type="button" class="btn btn-primary" data-toggle="modal"
+                    data-target="#addGoalCard">
+                Add Card
+            </button>
+        </div>
+        <table class="table {#table-striped#} table-responsive table-hover">
+            <thead class="table-header">
+            <tr>
+                <th>Card ID</th>
+                <th>Goal</th>
+                <th>Image</th>
+            </tr>
+            </thead>
+            <tbody>
+            {% for card in goal_cards %}
+                <tr>
+                    <td>{{ card.card_id }}</td>
+                    <td>{{ card.goal }}</td>
+                    <td>{% set filename = card.img_path.split('/')[-1] %}
+                        <a target="_blank" rel="noopener noreferrer"
+                           href="/api/get_card_image/{{ filename }}">{{ filename }}</a>
+                    </td>
+                    <td>
+                        <button type="button" class="btn btn-danger" onclick="delete_card('goal', {{ card.card_id }})">
+                            Delete
+                        </button>
+                    </td>
+                </tr>
+            {% endfor %}
+            </tbody>
+        </table>
+    </div>
+
+    <!-- Modal -->
+    <div class="modal fade" id="addGoalCard" tabindex="-1" role="dialog"
+         aria-labelledby="addGoalCardTitle"
+         aria-hidden="true">
+        <div class="modal-dialog modal-dialog-centered" role="document">
+            <div class="modal-content">
+                <div class="modal-header">
+                    <h5 class="modal-title" id="addGoalCardTitle">Add a Goal Card</h5>
+                    <button type="button" class="close" data-dismiss="modal" aria-label="Close">
+                        <span aria-hidden="true">&times;</span>
+                    </button>
+                </div>
+                <form id="submit-goal-card">
+                    <div class="modal-body">
+
+                        <div class="form-group">
+                            <label for="cardid_goal" class="col-form-label">Card ID: </label>
+                            <input type="number" class="form-control" id="cardid_goal" required>
+
+                            <label for="goal_goal" class="col-form-label">Goal: </label>
+                            <input type="text" class="form-control" id="goal_goal" required>
+
+                            <label for="img_goal" class="col-form-label">Image: </label>
+                            <input type="file" accept="image/png" class="form-control" id="img_goal" required>
+                        </div>
+
+                        <div id="response-msg-goal">
+
+                        </div>
+                    </div>
+                    <div class="modal-footer">
+                        <button type="button" class="btn btn-secondary" data-dismiss="modal">Cancel</button>
+                        <button type="submit" class="btn btn-primary">Submit & Add</button>
+                    </div>
+                </form>
+            </div>
+        </div>
+    </div>
+
+    <!-- Button trigger modal -->
+    <button type="button" class="btn btn-danger" data-toggle="modal" data-target="#exampleModalCenter">
+        Reset Database
+    </button>
+
+    <!-- Modal -->
+    <div class="modal fade" id="exampleModalCenter" tabindex="-1" role="dialog"
+         aria-labelledby="exampleModalCenterTitle"
+         aria-hidden="true">
+        <div class="modal-dialog modal-dialog-centered" role="document">
+            <div class="modal-content">
+                <div class="modal-header">
+                    <h5 class="modal-title" id="exampleModalLongTitle">Are you Sure you want to reset the Database?</h5>
+                    <button type="button" class="close" data-dismiss="modal" aria-label="Close">
+                        <span aria-hidden="true">&times;</span>
+                    </button>
+                </div>
+                <form id="confirm-pass">
+                    <div class="modal-body">
+                        <p>WARNING: All games and images will be permanently deleted!</p>
+                        <p>Retype your password to continue.</p>
+
+                        <div class="form-group">
+                            <label for="pass" class="col-form-label">Password:</label>
+                            <input type="password" class="form-control" id="pass" required>
+                        </div>
+
+                        <div id="response-msg">
+
+                        </div>
+                    </div>
+                    <div class="modal-footer">
+                        <button type="button" class="btn btn-secondary" data-dismiss="modal">Cancel</button>
+                        <button type="submit" class="btn btn-primary">Reset Database and Reload Page</button>
+                    </div>
+                </form>
+            </div>
+        </div>
+    </div>
+
+
+</div>
+
+<script type="text/javascript">
+
+    // Handle action card submission
+    document.getElementById('submit-action-card').addEventListener('submit', function (event) {
+        event.preventDefault(); // Prevent default form submission
+
+        // Create a new FormData object
+        const formData = new FormData();
+
+        // Retrieve the values from the inputs
+        const cardid = document.getElementById('cardid_action').value;
+        const action = document.getElementById('action_action').value;
+        const image = document.getElementById('img_action').files[0];
+
+        // Add the values to the FormData object
+        formData.append('cardid', cardid);
+        formData.append('action', action);
+        formData.append('img', image);
+
+        // Perform AJAX POST request
+        $.ajax({
+            type: "POST",
+            url: "/api/add_action_card",
+            data: formData,
+            processData: false,
+            contentType: false,
+            success: function (result) {
+                console.log(result)
+                location.reload()
+            },
+            error: function (result, status) {
+                console.log(result)
+                document.getElementById('response-msg-action').innerText = result.responseText
+            }
+        });
+    });
+
+    // Handle char card submission
+    document.getElementById('submit-char-card').addEventListener('submit', function (event) {
+        event.preventDefault(); // Prevent default form submission
+
+        // Create a new FormData object
+        const formData = new FormData();
+
+        // Retrieve the values from the inputs
+        const cardid = document.getElementById('cardid_char').value;
+        const name = document.getElementById('name_char').value;
+        const age = document.getElementById('age_char').value;
+        const role = document.getElementById('role_char').value;
+        const interest = document.getElementById('interest_char').value;
+        const quote = document.getElementById('quote_char').value;
+        const image = document.getElementById('img_char').files[0];
+
+        // Add the values to the FormData object
+        formData.append('cardid', cardid);
+        formData.append('name', name);
+        formData.append('age', age);
+        formData.append('role', role);
+        formData.append('interest', interest);
+        formData.append('quote', quote);
+        formData.append('img', image);
+
+        // Perform AJAX POST request
+        $.ajax({
+            type: "POST",
+            url: "/api/add_char_card",
+            data: formData,
+            processData: false,
+            contentType: false,
+            success: function (result) {
+                console.log(result)
+                location.reload()
+            },
+            error: function (result, status) {
+                console.log(result)
+                document.getElementById('response-msg-char').innerText = result.responseText
+            }
+        });
+    });
+
+    // Handle goal card submission
+    document.getElementById('submit-goal-card').addEventListener('submit', function (event) {
+        event.preventDefault(); // Prevent default form submission
+
+        // Create a new FormData object
+        const formData = new FormData();
+
+        // Retrieve the values from the inputs
+        const cardid = document.getElementById('cardid_goal').value;
+        const goal = document.getElementById('goal_goal').value;
+        const image = document.getElementById('img_goal').files[0];
+
+        // Add the values to the FormData object
+        formData.append('cardid', cardid);
+        formData.append('goal', goal);
+        formData.append('img', image);
+
+        // Perform AJAX POST request
+        $.ajax({
+            type: "POST",
+            url: "/api/add_goal_card",
+            data: formData,
+            processData: false,
+            contentType: false,
+            success: function (result) {
+                console.log(result)
+                location.reload()
+            },
+            error: function (result, status) {
+                console.log(result)
+                document.getElementById('response-msg-goal').innerText = result.responseText
+            }
+        });
+    });
+
+
+    $("#confirm-pass").submit(function (event) {
+        event.preventDefault(); //stop a full postback
+
+        let password = $("#pass").val(); //get the entered value from the password box
+
+        $.ajax({
+            type: "POST",
+            url: "/api/reset_database",
+            data: JSON.stringify({"password": password}),
+            contentType: "application/json",
+            success: function (result) {
+                location.reload()
+            },
+            error: function (result, status) {
+                document.getElementById('response-msg').innerText = result.responseText
+            }
+        });
+    });
+
+    function delete_card(type, id) {
+
+        let url;
+        switch (type) {
+            case "action":
+                url = "/api/delete_action_card";
+                break;
+            case "char":
+                url = "/api/delete_char_card";
+                break;
+            case "goal":
+                url = "/api/delete_goal_card";
+                break;
+        }
+
+        let fd = new FormData();
+        fd.append('cardid', id);
+
+        $.ajax({
+            type: "POST",
+            url: url,
+            data: fd,
+            processData: false,
+            contentType: false,
+            success: function (result) {
+                location.reload()
+            },
+            error: function (result, status) {
+                document.getElementById('response-msg').innerText = result.responseText
+            }
+        });
+    }
+
+</script>
+
+</body>
+</html>

+ 63 - 0
server/web/project/templates/login.html

@@ -0,0 +1,63 @@
+<!doctype html>
+
+<html>
+
+<head>
+    <meta charset="utf-8">
+    <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
+    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.4.1/dist/css/bootstrap.min.css"
+          integrity="sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh" crossorigin="anonymous">
+    <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
+    <script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.4.0/js/bootstrap.min.js"></script>
+
+    <title>Smart Hubs Points</title>
+</head>
+
+<body>
+    <div id="login" class="col-md-4 offset-md-4 mt-5">
+        <h2 class="login-header">Login</h2>
+        <form action="" method="POST">
+            {{ form.csrf_token }}
+            <div class="form-group">
+                <label>Username</label>
+                {{ form.username(_class="form-control", placeholder="your username") }}
+                {% if form.errors.username %}
+                    <div class="alert alert-danger" role="alert">
+                        <ul>
+                            {% for error in form.errors.username %}
+                                <li>{{ error }}</li>
+                            {% endfor %}
+                        </ul>
+                    </div>
+                {% endif %}
+            </div>
+            <div class="form-group">
+                <label>Password</label>
+                {{ form.password(_class="form-control") }}
+                {% if form.errors.password %}
+                    <div class="alert alert-danger" role="alert">
+                        <ul>
+                            {% for error in form.errors.password %}
+                                <li>{{ error }}</li>
+                            {% endfor %}
+                        </ul>
+                    </div>
+                {% endif %}
+            </div>
+            <button type="submit" class="btn btn-primary">Login!</button>
+        </form>
+        {% with messages = get_flashed_messages() %}
+            {% if messages %}
+                <div class="alert alert-warning">
+                    <ul class=flashes>
+                        {% for message in messages %}
+                            <li>{{ message }}</li>
+                        {% endfor %}
+                    </ul>
+                </div>
+            {% endif %}
+        {% endwith %}
+    </div>
+
+</body>
+</html>

BIN
server/web/requirements.txt