Updating to support categories
This commit is contained in:
@@ -8,5 +8,9 @@ RUN pip install -r requirements.txt
|
|||||||
RUN apt update && apt install -y ffmpeg
|
RUN apt update && apt install -y ffmpeg
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
# RUN the alembic migrations
|
||||||
|
RUN alembic upgrade head
|
||||||
|
|
||||||
CMD ["sh", "-c", "celery -A src.routers.signvideo worker --loglevel=INFO & python main.py"]
|
CMD ["sh", "-c", "celery -A src.routers.signvideo worker --loglevel=INFO & python main.py"]
|
||||||
# CMD ["python", "main.py"]
|
# CMD ["python", "main.py"]
|
||||||
113
backend/alembic.ini
Executable file
113
backend/alembic.ini
Executable file
@@ -0,0 +1,113 @@
|
|||||||
|
# A generic, single database configuration.
|
||||||
|
|
||||||
|
[alembic]
|
||||||
|
# path to migration scripts
|
||||||
|
script_location = alembic
|
||||||
|
|
||||||
|
transactional_ddl = True
|
||||||
|
|
||||||
|
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||||
|
# Uncomment the line below if you want the files to be prepended with date and time
|
||||||
|
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
||||||
|
# for all available tokens
|
||||||
|
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||||
|
|
||||||
|
# sys.path path, will be prepended to sys.path if present.
|
||||||
|
# defaults to the current working directory.
|
||||||
|
prepend_sys_path = .
|
||||||
|
|
||||||
|
# timezone to use when rendering the date within the migration file
|
||||||
|
# as well as the filename.
|
||||||
|
# If specified, requires the python-dateutil library that can be
|
||||||
|
# installed by adding `alembic[tz]` to the pip requirements
|
||||||
|
# string value is passed to dateutil.tz.gettz()
|
||||||
|
# leave blank for localtime
|
||||||
|
# timezone =
|
||||||
|
|
||||||
|
# max length of characters to apply to the
|
||||||
|
# "slug" field
|
||||||
|
# truncate_slug_length = 40
|
||||||
|
|
||||||
|
# set to 'true' to run the environment during
|
||||||
|
# the 'revision' command, regardless of autogenerate
|
||||||
|
# revision_environment = false
|
||||||
|
|
||||||
|
# set to 'true' to allow .pyc and .pyo files without
|
||||||
|
# a source .py file to be detected as revisions in the
|
||||||
|
# versions/ directory
|
||||||
|
# sourceless = false
|
||||||
|
|
||||||
|
# version location specification; This defaults
|
||||||
|
# to alembic/versions. When using multiple version
|
||||||
|
# directories, initial revisions must be specified with --version-path.
|
||||||
|
# The path separator used here should be the separator specified by "version_path_separator" below.
|
||||||
|
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
|
||||||
|
|
||||||
|
# version path separator; As mentioned above, this is the character used to split
|
||||||
|
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
|
||||||
|
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
|
||||||
|
# Valid values for version_path_separator are:
|
||||||
|
#
|
||||||
|
# version_path_separator = :
|
||||||
|
# version_path_separator = ;
|
||||||
|
# version_path_separator = space
|
||||||
|
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
|
||||||
|
|
||||||
|
# set to 'true' to search source files recursively
|
||||||
|
# in each "version_locations" directory
|
||||||
|
# new in Alembic version 1.10
|
||||||
|
# recursive_version_locations = false
|
||||||
|
|
||||||
|
# the output encoding used when revision files
|
||||||
|
# are written from script.py.mako
|
||||||
|
# output_encoding = utf-8
|
||||||
|
|
||||||
|
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||||
|
|
||||||
|
|
||||||
|
[post_write_hooks]
|
||||||
|
# post_write_hooks defines scripts or Python functions that are run
|
||||||
|
# on newly generated revision scripts. See the documentation for further
|
||||||
|
# detail and examples
|
||||||
|
|
||||||
|
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||||
|
# hooks = black
|
||||||
|
# black.type = console_scripts
|
||||||
|
# black.entrypoint = black
|
||||||
|
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||||
|
|
||||||
|
# Logging configuration
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[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
|
||||||
|
|
||||||
|
[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
|
||||||
|
|
||||||
1
backend/alembic/README
Normal file
1
backend/alembic/README
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Generic single-database configuration.
|
||||||
81
backend/alembic/env.py
Normal file
81
backend/alembic/env.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import asyncio
|
||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from sqlalchemy import engine_from_config, pool
|
||||||
|
from sqlmodel import Session, SQLModel, create_engine
|
||||||
|
from sqlmodel.ext.asyncio.session import AsyncEngine
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
from src.database.engine import engine
|
||||||
|
from src.models import * # necessarily to import something from file where your models are stored
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
# add your model's MetaData object here
|
||||||
|
# for 'autogenerate' support
|
||||||
|
# from myapp import mymodel
|
||||||
|
# target_metadata = mymodel.Base.metadata
|
||||||
|
target_metadata = None
|
||||||
|
|
||||||
|
# Interpret the config file for Python logging.
|
||||||
|
# This line sets up loggers basically.
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
# add your model's MetaData object here
|
||||||
|
# for 'autogenerate' support
|
||||||
|
# from myapp import mymodel
|
||||||
|
# target_metadata = mymodel.Base.metadata
|
||||||
|
target_metadata = None
|
||||||
|
# comment line above and instead of that write
|
||||||
|
target_metadata = SQLModel.metadata
|
||||||
|
|
||||||
|
|
||||||
|
async def run_migrations_offline() -> None:
|
||||||
|
"""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.
|
||||||
|
|
||||||
|
"""
|
||||||
|
connectable = engine
|
||||||
|
|
||||||
|
async with connectable.connect() as connection:
|
||||||
|
await connection.run_sync(do_run_migrations)
|
||||||
|
|
||||||
|
def do_run_migrations(connection):
|
||||||
|
context.configure(connection=connection, target_metadata=target_metadata, render_as_batch=True)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async def run_migrations_online() -> None:
|
||||||
|
"""Run migrations in 'online' mode.
|
||||||
|
|
||||||
|
In this scenario we need to create an Engine
|
||||||
|
and associate a connection with the context.
|
||||||
|
|
||||||
|
"""
|
||||||
|
connectable = engine
|
||||||
|
|
||||||
|
async with connectable.connect() as connection:
|
||||||
|
await connection.run_sync(do_run_migrations)
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
asyncio.run(run_migrations_offline())
|
||||||
|
else:
|
||||||
|
asyncio.run(run_migrations_online())
|
||||||
25
backend/alembic/script.py.mako
Normal file
25
backend/alembic/script.py.mako
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
"""${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 ""}
|
||||||
|
import sqlmodel
|
||||||
|
|
||||||
|
# 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() -> None:
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
46
backend/alembic/versions/2d2d4523082b_add_category_table.py
Executable file
46
backend/alembic/versions/2d2d4523082b_add_category_table.py
Executable file
@@ -0,0 +1,46 @@
|
|||||||
|
"""add category table
|
||||||
|
|
||||||
|
Revision ID: 2d2d4523082b
|
||||||
|
Revises:
|
||||||
|
Create Date: 2023-03-09 20:29:44.081609
|
||||||
|
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import sqlmodel
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
from src.models.category import Category
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '2d2d4523082b'
|
||||||
|
down_revision = None
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# create category table
|
||||||
|
Category.__table__.create(bind=op.get_bind())
|
||||||
|
|
||||||
|
op.bulk_insert(
|
||||||
|
Category.__table__,
|
||||||
|
[
|
||||||
|
{"id": 1, "name": "Alfabet"},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
with op.batch_alter_table('sign', schema=None) as batch_op:
|
||||||
|
# create column category_id
|
||||||
|
batch_op.add_column(sa.Column('category_id', sa.Integer(), nullable=True, default=1))
|
||||||
|
batch_op.create_foreign_key("sign_category", 'category', ['category_id'], ['id'])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('sign', schema=None) as batch_op:
|
||||||
|
batch_op.drop_constraint("sign_category", type_='foreignkey')
|
||||||
|
batch_op.drop_column('category_id')
|
||||||
|
# drop category table
|
||||||
|
Category.__table__.drop(bind=op.get_bind())
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -11,4 +11,5 @@ Pillow
|
|||||||
joblib
|
joblib
|
||||||
celery
|
celery
|
||||||
numpy
|
numpy
|
||||||
redis
|
redis
|
||||||
|
alembic
|
||||||
@@ -51,11 +51,13 @@ app.add_middleware(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# include the routers
|
# include the routers
|
||||||
from .routers import auth_router, signs_router, signvideo_router
|
from .routers import (auth_router, category_router, signs_router,
|
||||||
|
signvideo_router)
|
||||||
|
|
||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
app.include_router(signs_router)
|
app.include_router(signs_router)
|
||||||
app.include_router(signvideo_router)
|
app.include_router(signvideo_router)
|
||||||
|
app.include_router(category_router)
|
||||||
|
|
||||||
# Add the exception handlers
|
# Add the exception handlers
|
||||||
app.add_exception_handler(BaseException, base_exception_handler)
|
app.add_exception_handler(BaseException, base_exception_handler)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from typing import List, Optional
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlmodel import SQLModel
|
from sqlmodel import SQLModel
|
||||||
|
|
||||||
from src.database.crud import delete, read_all_where, read_where, update
|
from src.database.crud import delete, read_all_where, read_where, update
|
||||||
|
|
||||||
|
|
||||||
@@ -34,8 +35,8 @@ class SQLModelExtended(SQLModel):
|
|||||||
return res
|
return res
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def get_all_where(self, *args, session: AsyncSession):
|
async def get_all_where(self, *args, select_in_load: List = [], session: AsyncSession):
|
||||||
res = await read_all_where(self, *args, session=session)
|
res = await read_all_where(self, *args, select_in_load=select_in_load, session=session)
|
||||||
return res
|
return res
|
||||||
|
|
||||||
async def delete(self, session: AsyncSession) -> None:
|
async def delete(self, session: AsyncSession) -> None:
|
||||||
|
|||||||
5
backend/src/models/__init__.py
Executable file
5
backend/src/models/__init__.py
Executable file
@@ -0,0 +1,5 @@
|
|||||||
|
import glob
|
||||||
|
from os.path import basename, dirname, isfile, join
|
||||||
|
|
||||||
|
modules = glob.glob(join(dirname(__file__), "*.py"))
|
||||||
|
__all__ = [ basename(f)[:-3] for f in modules if isfile(f) and not f.endswith('__init__.py')]
|
||||||
54
backend/src/models/category.py
Executable file
54
backend/src/models/category.py
Executable file
@@ -0,0 +1,54 @@
|
|||||||
|
from typing import List
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlmodel import Field, Relationship
|
||||||
|
|
||||||
|
from src.models.sign import Sign, SignOut
|
||||||
|
from src.models.SQLModelExtended import SQLModelExtended
|
||||||
|
|
||||||
|
|
||||||
|
class Category(SQLModelExtended, table=True):
|
||||||
|
id: int = Field(primary_key=True)
|
||||||
|
|
||||||
|
name: str = Field(unique=True)
|
||||||
|
enabled: bool = Field(default=True)
|
||||||
|
|
||||||
|
# list of signs that belong to this category
|
||||||
|
signs: List[Sign] = Relationship(
|
||||||
|
back_populates="category",
|
||||||
|
sa_relationship_kwargs={"lazy": "selectin"},
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def get_random_sign(self, session: AsyncSession):
|
||||||
|
# get all categories
|
||||||
|
|
||||||
|
categories = await self.get_all_where(Category.enabled==True, select_in_load=[Category.signs],session=session)
|
||||||
|
|
||||||
|
# get all signs in one list with list comprehension
|
||||||
|
signs = [s for c in categories for s in c.signs]
|
||||||
|
|
||||||
|
sign_videos = [len(s.sign_videos) for s in signs]
|
||||||
|
|
||||||
|
random_prob = [1 / (x + 1) for x in sign_videos]
|
||||||
|
random_prob = random_prob / np.sum(random_prob)
|
||||||
|
|
||||||
|
# get random sign
|
||||||
|
sign = np.random.choice(signs, p=random_prob)
|
||||||
|
|
||||||
|
return sign
|
||||||
|
|
||||||
|
class CategoryPost(BaseModel):
|
||||||
|
name: str
|
||||||
|
|
||||||
|
class CategoryPut(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
enabled: bool = True
|
||||||
|
|
||||||
|
class CategoryOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
enabled: bool
|
||||||
@@ -3,7 +3,6 @@ from typing import List
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
import requests
|
import requests
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
from sqlmodel import Field, Relationship, SQLModel
|
from sqlmodel import Field, Relationship, SQLModel
|
||||||
|
|
||||||
from src.exceptions.base_exception import BaseException
|
from src.exceptions.base_exception import BaseException
|
||||||
@@ -25,8 +24,12 @@ class Sign(SQLModelExtended, table=True):
|
|||||||
sa_relationship_kwargs={"lazy": "selectin"},
|
sa_relationship_kwargs={"lazy": "selectin"},
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(self, url):
|
category_id: int = Field(foreign_key="category.id")
|
||||||
|
category: "Category" = Relationship(back_populates="signs")
|
||||||
|
|
||||||
|
def __init__(self, url, category_id):
|
||||||
self.url = url
|
self.url = url
|
||||||
|
self.category_id = category_id
|
||||||
|
|
||||||
# get name and sign id from url
|
# get name and sign id from url
|
||||||
try:
|
try:
|
||||||
@@ -53,23 +56,6 @@ class Sign(SQLModelExtended, table=True):
|
|||||||
if self.video_url is None:
|
if self.video_url is None:
|
||||||
raise BaseException(404, "Video url not found")
|
raise BaseException(404, "Video url not found")
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def get_random(self, session: AsyncSession):
|
|
||||||
signs = await self.get_all(select_in_load=[Sign.sign_videos],session=session)
|
|
||||||
|
|
||||||
sign_videos = [len(s.sign_videos) for s in signs]
|
|
||||||
|
|
||||||
# get probability based on number of videos, lower must be more likely
|
|
||||||
# the sum must be 1
|
|
||||||
|
|
||||||
random_prob = [1 / (x + 1) for x in sign_videos]
|
|
||||||
random_prob = random_prob / np.sum(random_prob)
|
|
||||||
|
|
||||||
# get random sign
|
|
||||||
sign = np.random.choice(signs, p=random_prob)
|
|
||||||
|
|
||||||
return sign
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -79,6 +65,7 @@ class SignOut(BaseModel):
|
|||||||
name: str
|
name: str
|
||||||
sign_id: str
|
sign_id: str
|
||||||
video_url: str
|
video_url: str
|
||||||
|
category_id: int
|
||||||
|
|
||||||
sign_videos: List[SignVideo] = []
|
sign_videos: List[SignVideo] = []
|
||||||
|
|
||||||
@@ -87,4 +74,5 @@ class SignOutSimple(BaseModel):
|
|||||||
url: str
|
url: str
|
||||||
name: str
|
name: str
|
||||||
sign_id: str
|
sign_id: str
|
||||||
video_url: str
|
video_url: str
|
||||||
|
category_id: int
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
from .auth import router as auth_router
|
from .auth import router as auth_router
|
||||||
|
from .category import router as category_router
|
||||||
from .signs import router as signs_router
|
from .signs import router as signs_router
|
||||||
from .signvideo import router as signvideo_router
|
from .signvideo import router as signvideo_router
|
||||||
|
|||||||
106
backend/src/routers/category.py
Executable file
106
backend/src/routers/category.py
Executable file
@@ -0,0 +1,106 @@
|
|||||||
|
from typing import List
|
||||||
|
|
||||||
|
from fastapi import APIRouter, BackgroundTasks, Depends, status
|
||||||
|
from fastapi_jwt_auth import AuthJWT
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.database.database import get_session
|
||||||
|
from src.exceptions.base_exception import BaseException
|
||||||
|
from src.exceptions.login_exception import LoginException
|
||||||
|
from src.models.auth import User
|
||||||
|
from src.models.category import (Category, CategoryOut, CategoryPost,
|
||||||
|
CategoryPut)
|
||||||
|
from src.models.sign import Sign, SignOut
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/categories")
|
||||||
|
|
||||||
|
@router.get("/", response_model=List[Category])
|
||||||
|
async def get_categories(Authorize: AuthJWT = Depends(), session: AsyncSession = Depends(get_session)):
|
||||||
|
Authorize.jwt_required()
|
||||||
|
|
||||||
|
user = Authorize.get_jwt_subject()
|
||||||
|
user = await User.get_by_id(id=user, session=session)
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
raise LoginException("User not found")
|
||||||
|
|
||||||
|
categories = await Category.get_all(session=session, select_in_load=[Category.signs])
|
||||||
|
return categories
|
||||||
|
|
||||||
|
@router.get("/{category_id}/signs", response_model=List[SignOut])
|
||||||
|
async def get_signs_by_category(category_id: int, Authorize: AuthJWT = Depends(), session: AsyncSession = Depends(get_session)):
|
||||||
|
Authorize.jwt_required()
|
||||||
|
|
||||||
|
user = Authorize.get_jwt_subject()
|
||||||
|
user = await User.get_by_id(id=user, session=session)
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
raise LoginException("User not found")
|
||||||
|
|
||||||
|
signs = await Sign.get_all_where(Sign.category_id==category_id, select_in_load=[Sign.sign_videos], session=session)
|
||||||
|
return signs
|
||||||
|
|
||||||
|
@router.post("/", status_code=status.HTTP_201_CREATED, response_model=CategoryOut)
|
||||||
|
async def create_category(category: CategoryPost, Authorize: AuthJWT = Depends(), session: AsyncSession = Depends(get_session)):
|
||||||
|
Authorize.jwt_required()
|
||||||
|
|
||||||
|
user = Authorize.get_jwt_subject()
|
||||||
|
user = await User.get_by_id(id=user, session=session)
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
raise LoginException("User not found")
|
||||||
|
|
||||||
|
# Category name cannot be empty or only exist of spaces
|
||||||
|
if not category.name or category.name.isspace():
|
||||||
|
raise BaseException(message="Category name cannot be empty", status_code=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
try:
|
||||||
|
c = Category(name=category.name)
|
||||||
|
await c.save(session=session)
|
||||||
|
except Exception as e:
|
||||||
|
raise BaseException(message="Category already exists", status_code=status.HTTP_400_BAD_REQUEST)
|
||||||
|
return c
|
||||||
|
|
||||||
|
@router.put("/", response_model=CategoryOut)
|
||||||
|
async def update_category(category: CategoryPut, Authorize: AuthJWT = Depends(), session: AsyncSession = Depends(get_session)):
|
||||||
|
Authorize.jwt_required()
|
||||||
|
|
||||||
|
user = Authorize.get_jwt_subject()
|
||||||
|
user = await User.get_by_id(id=user, session=session)
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
raise LoginException("User not found")
|
||||||
|
|
||||||
|
# Category name cannot be empty or only exist of spaces
|
||||||
|
if not category.name or category.name.isspace():
|
||||||
|
raise BaseException(message="Category name cannot be empty", status_code=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
c = await Category.get_by_id(id=category.id, session=session)
|
||||||
|
if not c:
|
||||||
|
raise BaseException(message="Category not found", status_code=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
c.name = category.name
|
||||||
|
c.enabled = category.enabled
|
||||||
|
await c.save(session=session)
|
||||||
|
return c
|
||||||
|
|
||||||
|
@router.delete("/{category_id}")
|
||||||
|
async def delete_category(category_id: int, Authorize: AuthJWT = Depends(), session: AsyncSession = Depends(get_session)):
|
||||||
|
Authorize.jwt_required()
|
||||||
|
|
||||||
|
user = Authorize.get_jwt_subject()
|
||||||
|
user = await User.get_by_id(id=user, session=session)
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
raise LoginException("User not found")
|
||||||
|
|
||||||
|
c = await Category.get_by_id(id=category_id, session=session)
|
||||||
|
print(c)
|
||||||
|
if not c:
|
||||||
|
raise BaseException(message="Category not found", status_code=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
if (len(c.signs) > 0):
|
||||||
|
raise BaseException(message="Category cannot be deleted because it contains signs", status_code=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
await c.delete(session=session)
|
||||||
|
return {"message": "Category deleted successfully"}
|
||||||
@@ -12,24 +12,25 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
import src.settings as settings
|
import src.settings as settings
|
||||||
from src.database.database import get_session
|
from src.database.database import get_session
|
||||||
from src.exceptions.login_exception import LoginException
|
from src.exceptions.login_exception import LoginException
|
||||||
from src.models.auth import Login, User
|
from src.models.auth import User
|
||||||
|
from src.models.category import Category
|
||||||
from src.models.sign import Sign, SignOut, SignOutSimple
|
from src.models.sign import Sign, SignOut, SignOutSimple
|
||||||
from src.models.signvideo import SignVideo
|
from src.models.signvideo import SignVideo
|
||||||
from src.models.token import TokenExtended
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/signs")
|
router = APIRouter(prefix="/signs")
|
||||||
|
|
||||||
class SignUrl(BaseModel):
|
class SignIn(BaseModel):
|
||||||
|
category: int
|
||||||
url: str
|
url: str
|
||||||
|
|
||||||
@router.get("/random", status_code=status.HTTP_200_OK, response_model=SignOutSimple)
|
@router.get("/random", status_code=status.HTTP_200_OK, response_model=SignOutSimple)
|
||||||
async def get_random_sign(session: AsyncSession = Depends(get_session)):
|
async def get_random_sign(session: AsyncSession = Depends(get_session)):
|
||||||
# get a random sign where there is not much data from yet
|
# get a random sign where there is not much data from yet
|
||||||
sign = await Sign.get_random(session=session)
|
sign = await Category.get_random_sign(session=session)
|
||||||
return sign
|
return sign
|
||||||
|
|
||||||
@router.post("/", status_code=status.HTTP_201_CREATED, response_model=SignOut)
|
@router.post("/", status_code=status.HTTP_201_CREATED, response_model=SignOut)
|
||||||
async def add_sign(url: SignUrl, Authorize: AuthJWT = Depends(), session: AsyncSession = Depends(get_session)):
|
async def add_sign(sign: SignIn, Authorize: AuthJWT = Depends(), session: AsyncSession = Depends(get_session)):
|
||||||
Authorize.jwt_required()
|
Authorize.jwt_required()
|
||||||
|
|
||||||
user = Authorize.get_jwt_subject()
|
user = Authorize.get_jwt_subject()
|
||||||
@@ -38,7 +39,7 @@ async def add_sign(url: SignUrl, Authorize: AuthJWT = Depends(), session: AsyncS
|
|||||||
if not user:
|
if not user:
|
||||||
raise LoginException("User not found")
|
raise LoginException("User not found")
|
||||||
|
|
||||||
sign = Sign(url.url)
|
sign = Sign(url=sign.url, category_id=sign.category)
|
||||||
|
|
||||||
# check if the sign already exists
|
# check if the sign already exists
|
||||||
signs = await Sign.get_all_where(Sign.url == sign.url, session=session)
|
signs = await Sign.get_all_where(Sign.url == sign.url, session=session)
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ffmpeg/core": "^0.11.0",
|
"@ffmpeg/core": "^0.11.0",
|
||||||
"@ffmpeg/ffmpeg": "^0.11.6",
|
"@ffmpeg/ffmpeg": "^0.11.6",
|
||||||
|
"@heroicons/react": "^2.0.16",
|
||||||
|
"@styled-icons/heroicons-solid": "^10.47.0",
|
||||||
"@testing-library/jest-dom": "^5.14.1",
|
"@testing-library/jest-dom": "^5.14.1",
|
||||||
"@testing-library/react": "^13.0.0",
|
"@testing-library/react": "^13.0.0",
|
||||||
"@testing-library/user-event": "^13.2.1",
|
"@testing-library/user-event": "^13.2.1",
|
||||||
@@ -13,6 +15,7 @@
|
|||||||
"@types/node": "^16.7.13",
|
"@types/node": "^16.7.13",
|
||||||
"@types/react": "^18.0.26",
|
"@types/react": "^18.0.26",
|
||||||
"@types/react-dom": "^18.0.10",
|
"@types/react-dom": "^18.0.10",
|
||||||
|
"@types/react-edit-text": "^5.0.1",
|
||||||
"@types/react-modal": "^3.13.1",
|
"@types/react-modal": "^3.13.1",
|
||||||
"@types/react-transition-group": "^4.4.5",
|
"@types/react-transition-group": "^4.4.5",
|
||||||
"axios": "^1.2.2",
|
"axios": "^1.2.2",
|
||||||
@@ -22,6 +25,7 @@
|
|||||||
"fluent-ffmpeg": "^2.1.2",
|
"fluent-ffmpeg": "^2.1.2",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
|
"react-edit-text": "^5.0.2",
|
||||||
"react-media-recorder": "^1.6.6",
|
"react-media-recorder": "^1.6.6",
|
||||||
"react-modal": "^3.16.1",
|
"react-modal": "^3.16.1",
|
||||||
"react-native-trimmer": "^1.1.1",
|
"react-native-trimmer": "^1.1.1",
|
||||||
|
|||||||
@@ -5,15 +5,17 @@ import ProtectedRoute from './components/ProtectedRoute';
|
|||||||
import RandomSignUpload from './components/RandomVideoUpload';
|
import RandomSignUpload from './components/RandomVideoUpload';
|
||||||
import SignDetailpage from './components/SignDetailPage';
|
import SignDetailpage from './components/SignDetailPage';
|
||||||
import SignsPage from './components/SignsPage';
|
import SignsPage from './components/SignsPage';
|
||||||
|
import CategoriesPage from './components/CategoryPage';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<Router>
|
<Router>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<RandomSignUpload />} />
|
<Route path="/" element={<RandomSignUpload />} />
|
||||||
<Route path="/admin/" element={<ProtectedRoute><SignsPage /></ProtectedRoute>} />
|
<Route path="/admin/" element={<ProtectedRoute><CategoriesPage /></ProtectedRoute>} />
|
||||||
|
<Route path="/admin/categories/:id" element={<ProtectedRoute><SignsPage /></ProtectedRoute>} />
|
||||||
<Route path="/admin/login" element={<Login />} />
|
<Route path="/admin/login" element={<Login />} />
|
||||||
<Route path="/admin/signs/:id" element={<ProtectedRoute><SignDetailpage /></ProtectedRoute>} />
|
<Route path="/admin/categories/:category_id/signs/:id" element={<ProtectedRoute><SignDetailpage /></ProtectedRoute>} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Router>
|
</Router>
|
||||||
);
|
);
|
||||||
|
|||||||
98
frontend/src/components/CategorieComponent.tsx
Executable file
98
frontend/src/components/CategorieComponent.tsx
Executable file
@@ -0,0 +1,98 @@
|
|||||||
|
import React, { MouseEventHandler, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Category } from '../types/category';
|
||||||
|
import { Sign } from '../types/sign';
|
||||||
|
import { EditText, EditTextarea, EditTextProps } from 'react-edit-text';
|
||||||
|
import 'react-edit-text/dist/index.css';
|
||||||
|
import { deleteCategory, updateCategory } from '../services/category';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
category: Category;
|
||||||
|
deleteCategory: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SignComponent: React.FC<Props> = (props) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [category, setCategory] = useState<Category>(props.category);
|
||||||
|
const [name, setName] = useState<string>(category.name);
|
||||||
|
|
||||||
|
const onClick = (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
navigate(`/admin/categories/${category.id}/`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = (p: any) => {
|
||||||
|
updateCategory({ ...category, "name": p.value })
|
||||||
|
.then((response) => {
|
||||||
|
setCategory({
|
||||||
|
...category,
|
||||||
|
name: p.value
|
||||||
|
});
|
||||||
|
setName(p.value);
|
||||||
|
})
|
||||||
|
|
||||||
|
.catch((error) => {
|
||||||
|
setName(category.name);
|
||||||
|
alert(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEnableSwitch = (e: any) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
updateCategory({ ...category, "enabled": !category.enabled })
|
||||||
|
.then((response) => {
|
||||||
|
setCategory({
|
||||||
|
...category,
|
||||||
|
enabled: !category.enabled
|
||||||
|
});
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.catch((error) => {
|
||||||
|
alert(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
props.deleteCategory();
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>, setFn: { (value: React.SetStateAction<string>): void; (arg0: any): void; }) => {
|
||||||
|
setFn(e.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative bg-white p-6 rounded-lg shadow-md" onClick={onClick}>
|
||||||
|
<button className="absolute top-2 right-2" onClick={handleDeleteClick}>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
|
<EditText className="text-2xl font-bold" value={name} onChange={(e) => handleChange(e, setName)} onSave={handleSave} />
|
||||||
|
</div>
|
||||||
|
<div className='mt-4 flex'>
|
||||||
|
<h2>Publically:</h2>
|
||||||
|
<label className="ml-4 inline-flex relative items-center mr-5 cursor-pointer" onClick={handleEnableSwitch}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="sr-only peer"
|
||||||
|
checked={category.enabled}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="w-11 h-6 bg-gray-200 rounded-full peer peer-focus:ring-green-300 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-green-600"
|
||||||
|
></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SignComponent;
|
||||||
76
frontend/src/components/CategoryPage.tsx
Executable file
76
frontend/src/components/CategoryPage.tsx
Executable file
@@ -0,0 +1,76 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { getCategories, addCategory, deleteCategory } from '../services/category';
|
||||||
|
import { Category } from '../types/category';
|
||||||
|
import { Sign } from '../types/sign';
|
||||||
|
import SignComponent from './SignComponent';
|
||||||
|
import CategorieComponent from './CategorieComponent';
|
||||||
|
|
||||||
|
|
||||||
|
const CategoriesPage: React.FC = () => {
|
||||||
|
const [categories, setCategories] = useState<Category[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [newCategory, setNewCategory] = useState<string>('');
|
||||||
|
const [newCategoryError, setNewCategoryError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// get the signs from the api
|
||||||
|
getCategories().then((categories) => {
|
||||||
|
setCategories(categories);
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handle_add_category = () => {
|
||||||
|
addCategory(newCategory).then((category) => {
|
||||||
|
setCategories([...categories, category]);
|
||||||
|
setNewCategory('');
|
||||||
|
}).catch((error) => {
|
||||||
|
console.log(error);
|
||||||
|
setNewCategoryError(error.message);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteCategory = (category: Category) => {
|
||||||
|
deleteCategory(category.id).then(() => {
|
||||||
|
setCategories(categories.filter((c) => c.id !== category.id));
|
||||||
|
}
|
||||||
|
).catch((error) => {
|
||||||
|
console.log(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-left bg-gray-100 min-h-screen">
|
||||||
|
<div className="bg-white p-6 rounded-lg shadow-md">
|
||||||
|
<div className="mb-4">
|
||||||
|
<input
|
||||||
|
className="border border-gray-400 p-2 rounded-lg w-full"
|
||||||
|
type="text"
|
||||||
|
value={newCategory}
|
||||||
|
onChange={(event) => setNewCategory(event.target.value)}
|
||||||
|
placeholder="Enter Category Name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button className="bg-indigo-500 text-white py-2 px-4 rounded-lg hover:bg-indigo-600" onClick={handle_add_category}>
|
||||||
|
Add Category
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{newCategoryError && <p className="text-red-500">{newCategoryError}</p>}
|
||||||
|
|
||||||
|
<h1 className="text-3xl font-bold mt-5">Categories</h1>
|
||||||
|
{!loading ?
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-5 mt-5">
|
||||||
|
{
|
||||||
|
categories.map((category) => <CategorieComponent key={category.id} category={category} deleteCategory={() => handleDeleteCategory(category)} />)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
:
|
||||||
|
<p>Loading...</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CategoriesPage;
|
||||||
@@ -14,7 +14,7 @@ const SignComponent: React.FC<Props> = ({ sign, deleteSign }) => {
|
|||||||
|
|
||||||
const onClick = (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
const onClick = (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
navigate(`/admin/signs/${sign.id}`);
|
navigate(`/admin/categories/${sign.category_id}/signs/${sign.id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
const handleDeleteClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ const SignDetailpage: React.FC<Props> = (props) => {
|
|||||||
}
|
}
|
||||||
<div>
|
<div>
|
||||||
<button onClick={() => {
|
<button onClick={() => {
|
||||||
window.location.href = '/admin/';
|
window.location.href = `/admin/categories/${sign!.category_id}`;
|
||||||
}} className="bg-white p-2 rounded-full text-red-600 hover:bg-red-600 hover:text-white absolute top-1 left-1">
|
}} className="bg-white p-2 rounded-full text-red-600 hover:bg-red-600 hover:text-white absolute top-1 left-1">
|
||||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||||
|
|||||||
@@ -1,17 +1,25 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { getSigns, addSign, downloadSigns, deleteSign } from '../services/signs';
|
import { getSigns, addSign, downloadSigns, deleteSign } from '../services/signs';
|
||||||
|
import { Category } from '../types/category';
|
||||||
import { Sign } from '../types/sign';
|
import { Sign } from '../types/sign';
|
||||||
import SignComponent from './SignComponent';
|
import SignComponent from './SignComponent';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
const SignsPage: React.FC = () => {
|
const SignsPage: React.FC = () => {
|
||||||
|
|
||||||
const [signs, setSigns] = useState<Sign[]>([]);
|
const [signs, setSigns] = useState<Sign[]>([]);
|
||||||
const [newSign, setNewSign] = useState('');
|
const [newSign, setNewSign] = useState('');
|
||||||
const [newSignError, setNewSignError] = useState<string | null>(null);
|
const [newSignError, setNewSignError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const { id } = useParams<Params>();
|
||||||
|
|
||||||
const handleAddSign = async () => {
|
const handleAddSign = async () => {
|
||||||
addSign(newSign).then((sign) => {
|
addSign(newSign, id!).then((sign) => {
|
||||||
setSigns([...signs, sign]);
|
setSigns([...signs, sign]);
|
||||||
setNewSign('');
|
setNewSign('');
|
||||||
setNewSignError(null);
|
setNewSignError(null);
|
||||||
@@ -44,8 +52,7 @@ const SignsPage: React.FC = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// get the signs from the api
|
// get the signs from the api
|
||||||
getSigns().then((signs) => {
|
getSigns(id!).then((signs) => {
|
||||||
console.log(signs)
|
|
||||||
setSigns(signs);
|
setSigns(signs);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
});
|
});
|
||||||
@@ -63,6 +70,15 @@ const SignsPage: React.FC = () => {
|
|||||||
placeholder="Enter sign url"
|
placeholder="Enter sign url"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<button onClick={() => {
|
||||||
|
window.location.href = `/admin/`;
|
||||||
|
}} className="bg-white p-2 rounded-full text-red-600 hover:bg-red-600 hover:text-white absolute top-1 left-1">
|
||||||
|
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<button className="bg-indigo-500 text-white py-2 px-4 rounded-lg hover:bg-indigo-600" onClick={handleAddSign}>
|
<button className="bg-indigo-500 text-white py-2 px-4 rounded-lg hover:bg-indigo-600" onClick={handleAddSign}>
|
||||||
Add Sign
|
Add Sign
|
||||||
</button>
|
</button>
|
||||||
@@ -70,8 +86,12 @@ const SignsPage: React.FC = () => {
|
|||||||
Download Data
|
Download Data
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{newSignError && <p className="text-red-500">{newSignError}</p>}
|
{newSignError && <p className="text-red-500">{newSignError}</p>}
|
||||||
|
|
||||||
|
<h1 className="text-3xl font-bold mt-5">Signs</h1>
|
||||||
|
|
||||||
|
|
||||||
{!loading ?
|
{!loading ?
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-5 mt-5">
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-5 mt-5">
|
||||||
{
|
{
|
||||||
|
|||||||
85
frontend/src/services/category.ts
Executable file
85
frontend/src/services/category.ts
Executable file
@@ -0,0 +1,85 @@
|
|||||||
|
import { Category } from "../types/category";
|
||||||
|
|
||||||
|
const getCategories = async () => {
|
||||||
|
// get access token from local storage
|
||||||
|
const token = localStorage.getItem('accessToken');
|
||||||
|
// make request to get signs
|
||||||
|
const response = await fetch(`${process.env.REACT_APP_API_URL}/categories/`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// return the response
|
||||||
|
return response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
const addCategory = async (category: string) => {
|
||||||
|
// get access token from local storage
|
||||||
|
const token = localStorage.getItem('accessToken');
|
||||||
|
// make request to get signs
|
||||||
|
const response = await fetch(`${process.env.REACT_APP_API_URL}/categories/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ "name": category })
|
||||||
|
});
|
||||||
|
|
||||||
|
// check if error
|
||||||
|
if (response.status !== 201) {
|
||||||
|
throw new Error('Error adding category');
|
||||||
|
}
|
||||||
|
|
||||||
|
// return the response
|
||||||
|
return response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateCategory = async (category: Category) => {
|
||||||
|
// get access token from local storage
|
||||||
|
const token = localStorage.getItem('accessToken');
|
||||||
|
// make request to get signs
|
||||||
|
const response = await fetch(`${process.env.REACT_APP_API_URL}/categories/`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(category)
|
||||||
|
});
|
||||||
|
|
||||||
|
// check if error
|
||||||
|
if (response.status !== 200) {
|
||||||
|
throw new Error('Error updating category');
|
||||||
|
}
|
||||||
|
|
||||||
|
// return the response
|
||||||
|
return response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteCategory = async (id: number) => {
|
||||||
|
// get access token from local storage
|
||||||
|
const token = localStorage.getItem('accessToken');
|
||||||
|
// make request to get signs
|
||||||
|
const response = await fetch(`${process.env.REACT_APP_API_URL}/categories/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// check if error
|
||||||
|
if (response.status !== 200) {
|
||||||
|
throw new Error('Category must be empty before deleting');
|
||||||
|
}
|
||||||
|
|
||||||
|
// return the response
|
||||||
|
return response.json();
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export { getCategories, addCategory, updateCategory, deleteCategory };
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
const getSigns = async () => {
|
const getSigns = async (category: string) => {
|
||||||
// get access token from local storage
|
// get access token from local storage
|
||||||
const token = localStorage.getItem('accessToken');
|
const token = localStorage.getItem('accessToken');
|
||||||
// make request to get signs
|
// make request to get signs
|
||||||
const response = await fetch(`${process.env.REACT_APP_API_URL}/signs/`, {
|
const response = await fetch(`${process.env.REACT_APP_API_URL}/categories/${category}/signs/`, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`
|
Authorization: `Bearer ${token}`
|
||||||
}
|
}
|
||||||
@@ -12,7 +12,7 @@ const getSigns = async () => {
|
|||||||
return response.json();
|
return response.json();
|
||||||
};
|
};
|
||||||
|
|
||||||
const addSign = async (url: string) => {
|
const addSign = async (url: string, category: string) => {
|
||||||
// get access token from local storage
|
// get access token from local storage
|
||||||
const token = localStorage.getItem('accessToken');
|
const token = localStorage.getItem('accessToken');
|
||||||
// make request to add sign
|
// make request to add sign
|
||||||
@@ -22,7 +22,7 @@ const addSign = async (url: string) => {
|
|||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ "url": url })
|
body: JSON.stringify({ "url": url, "category": category })
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
7
frontend/src/types/category.ts
Executable file
7
frontend/src/types/category.ts
Executable file
@@ -0,0 +1,7 @@
|
|||||||
|
import { Sign } from "./sign";
|
||||||
|
|
||||||
|
export interface Category {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ export interface Sign {
|
|||||||
name: string;
|
name: string;
|
||||||
sign_id: string;
|
sign_id: string;
|
||||||
video_url: string;
|
video_url: string;
|
||||||
|
category_id: number;
|
||||||
sign_videos: [SignVideo];
|
sign_videos: [SignVideo];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ export interface SimpleSign {
|
|||||||
id: number;
|
id: number;
|
||||||
url: string;
|
url: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
category_id: number;
|
||||||
sign_id: string;
|
sign_id: string;
|
||||||
video_url: string;
|
video_url: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1036,6 +1036,13 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
regenerator-runtime "^0.13.11"
|
regenerator-runtime "^0.13.11"
|
||||||
|
|
||||||
|
"@babel/runtime@^7.19.0":
|
||||||
|
version "7.21.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.21.0.tgz#5b55c9d394e5fcf304909a8b00c07dc217b56673"
|
||||||
|
integrity sha512-xwII0//EObnq89Ji5AKYQaRYiW/nZ3llSv29d49IuxPhKbtJoLP+9QUUZ4nVragQVtaVGeZrpB+ZtG/Pdy/POw==
|
||||||
|
dependencies:
|
||||||
|
regenerator-runtime "^0.13.11"
|
||||||
|
|
||||||
"@babel/template@^7.18.10", "@babel/template@^7.20.7", "@babel/template@^7.3.3":
|
"@babel/template@^7.18.10", "@babel/template@^7.20.7", "@babel/template@^7.3.3":
|
||||||
version "7.20.7"
|
version "7.20.7"
|
||||||
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.20.7.tgz#a15090c2839a83b02aa996c0b4994005841fd5a8"
|
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.20.7.tgz#a15090c2839a83b02aa996c0b4994005841fd5a8"
|
||||||
@@ -1238,6 +1245,11 @@
|
|||||||
regenerator-runtime "^0.13.7"
|
regenerator-runtime "^0.13.7"
|
||||||
resolve-url "^0.2.1"
|
resolve-url "^0.2.1"
|
||||||
|
|
||||||
|
"@heroicons/react@^2.0.16":
|
||||||
|
version "2.0.16"
|
||||||
|
resolved "https://registry.yarnpkg.com/@heroicons/react/-/react-2.0.16.tgz#562883c19ba2690c83380b42a9a5cce39dcbdb4a"
|
||||||
|
integrity sha512-x89rFxH3SRdYaA+JCXwfe+RkE1SFTo9GcOkZettHer71Y3T7V+ogKmfw5CjTazgS3d0ClJ7p1NA+SP7VQLQcLw==
|
||||||
|
|
||||||
"@humanwhocodes/config-array@^0.11.8":
|
"@humanwhocodes/config-array@^0.11.8":
|
||||||
version "0.11.8"
|
version "0.11.8"
|
||||||
resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.8.tgz#03595ac2075a4dc0f191cc2131de14fbd7d410b9"
|
resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.8.tgz#03595ac2075a4dc0f191cc2131de14fbd7d410b9"
|
||||||
@@ -1671,6 +1683,21 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@sinonjs/commons" "^1.7.0"
|
"@sinonjs/commons" "^1.7.0"
|
||||||
|
|
||||||
|
"@styled-icons/heroicons-solid@^10.47.0":
|
||||||
|
version "10.47.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@styled-icons/heroicons-solid/-/heroicons-solid-10.47.0.tgz#4457463fe15c8bf8c357bf22dd16d3e579a5e163"
|
||||||
|
integrity sha512-j+tJx2NzLG2tc91IXJVwKNjsI/osxmak+wmLfnfBsB+49srpxMYjuLPMtl9ZY/xgbNsWO36O+/N5Zf5bkgiKcQ==
|
||||||
|
dependencies:
|
||||||
|
"@babel/runtime" "^7.20.7"
|
||||||
|
"@styled-icons/styled-icon" "^10.7.0"
|
||||||
|
|
||||||
|
"@styled-icons/styled-icon@^10.7.0":
|
||||||
|
version "10.7.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@styled-icons/styled-icon/-/styled-icon-10.7.0.tgz#d6960e719b8567c8d0d3a87c40fb6f5b4952a228"
|
||||||
|
integrity sha512-SCrhCfRyoY8DY7gUkpz+B0RqUg/n1Zaqrr2+YKmK/AyeNfCcoHuP4R9N4H0p/NA1l7PTU10ZkAWSLi68phnAjw==
|
||||||
|
dependencies:
|
||||||
|
"@babel/runtime" "^7.19.0"
|
||||||
|
|
||||||
"@surma/rollup-plugin-off-main-thread@^2.2.3":
|
"@surma/rollup-plugin-off-main-thread@^2.2.3":
|
||||||
version "2.2.3"
|
version "2.2.3"
|
||||||
resolved "https://registry.yarnpkg.com/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz#ee34985952ca21558ab0d952f00298ad2190c053"
|
resolved "https://registry.yarnpkg.com/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz#ee34985952ca21558ab0d952f00298ad2190c053"
|
||||||
@@ -2088,6 +2115,13 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@types/react" "*"
|
"@types/react" "*"
|
||||||
|
|
||||||
|
"@types/react-edit-text@^5.0.1":
|
||||||
|
version "5.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/react-edit-text/-/react-edit-text-5.0.1.tgz#2907c740c013c17707eb76d1b84688417dff4c27"
|
||||||
|
integrity sha512-Eb2tX+PtSsbSTZPO1mTbTufVVAGddeplMCHsL+RCXQytm3X8bkiKWXMbQ/kaoOuxDHTkarItHRX0dEeo5A2B+w==
|
||||||
|
dependencies:
|
||||||
|
"@types/react" "*"
|
||||||
|
|
||||||
"@types/react-modal@^3.13.1":
|
"@types/react-modal@^3.13.1":
|
||||||
version "3.13.1"
|
version "3.13.1"
|
||||||
resolved "https://registry.yarnpkg.com/@types/react-modal/-/react-modal-3.13.1.tgz#5b9845c205fccc85d9a77966b6e16dc70a60825a"
|
resolved "https://registry.yarnpkg.com/@types/react-modal/-/react-modal-3.13.1.tgz#5b9845c205fccc85d9a77966b6e16dc70a60825a"
|
||||||
@@ -3234,7 +3268,7 @@ cjs-module-lexer@^1.0.0:
|
|||||||
resolved "https://registry.yarnpkg.com/cjs-module-lexer/-/cjs-module-lexer-1.2.2.tgz#9f84ba3244a512f3a54e5277e8eef4c489864e40"
|
resolved "https://registry.yarnpkg.com/cjs-module-lexer/-/cjs-module-lexer-1.2.2.tgz#9f84ba3244a512f3a54e5277e8eef4c489864e40"
|
||||||
integrity sha512-cOU9usZw8/dXIXKtwa8pM0OTJQuJkxMN6w30csNRUerHfeQ5R6U3kkU/FtJeIf3M202OHfY2U8ccInBG7/xogA==
|
integrity sha512-cOU9usZw8/dXIXKtwa8pM0OTJQuJkxMN6w30csNRUerHfeQ5R6U3kkU/FtJeIf3M202OHfY2U8ccInBG7/xogA==
|
||||||
|
|
||||||
classnames@^2.2.1:
|
classnames@^2.2.1, classnames@^2.2.6:
|
||||||
version "2.3.2"
|
version "2.3.2"
|
||||||
resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.2.tgz#351d813bf0137fcc6a76a16b88208d2560a0d924"
|
resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.2.tgz#351d813bf0137fcc6a76a16b88208d2560a0d924"
|
||||||
integrity sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw==
|
integrity sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw==
|
||||||
@@ -8035,6 +8069,14 @@ react-dom@^18.2.0:
|
|||||||
loose-envify "^1.1.0"
|
loose-envify "^1.1.0"
|
||||||
scheduler "^0.23.0"
|
scheduler "^0.23.0"
|
||||||
|
|
||||||
|
react-edit-text@^5.0.2:
|
||||||
|
version "5.0.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/react-edit-text/-/react-edit-text-5.0.2.tgz#e1418a16f615cc5b833f455ccffd24d18c566ab2"
|
||||||
|
integrity sha512-V3M4KhgQDbKa1I6F5OLrhqu9w/T4XONFEyC4RijdSxDs+L5srpsCH6V5CSWGNT6pu6f4VG03n736RB3lMCTUkw==
|
||||||
|
dependencies:
|
||||||
|
classnames "^2.2.6"
|
||||||
|
prop-types "^15.8.1"
|
||||||
|
|
||||||
react-error-overlay@^6.0.11:
|
react-error-overlay@^6.0.11:
|
||||||
version "6.0.11"
|
version "6.0.11"
|
||||||
resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.11.tgz#92835de5841c5cf08ba00ddd2d677b6d17ff9adb"
|
resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.11.tgz#92835de5841c5cf08ba00ddd2d677b6d17ff9adb"
|
||||||
|
|||||||
Reference in New Issue
Block a user