Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions backend/alembic/versions/62f72c3ab3a5_favorite_and_recent_cards.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""favorite and recent cards

Revision ID: 62f72c3ab3a5
Revises: 21459fe9053f
Create Date: 2025-07-04 00:20:18.908767

"""

from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision: str = "62f72c3ab3a5"
down_revision: Union[str, None] = "21459fe9053f"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
"""Upgrade schema."""
with op.batch_alter_table("cards", schema=None) as batch_op:
batch_op.add_column(
sa.Column(
"is_favorite",
sa.Boolean(),
server_default=sa.text("(FALSE)"),
nullable=False,
),
)
batch_op.add_column(sa.Column("used_at", sa.DateTime(), nullable=True))


def downgrade() -> None:
with op.batch_alter_table("cards", schema=None) as batch_op:
batch_op.drop_column("is_favorite")
batch_op.drop_column("used_at")
28 changes: 26 additions & 2 deletions backend/app/api/card.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ async def create_card(
@router.put("/cards/{card_id}", response_model=schemas.Card)
async def update_card(
card_id: int,
updated: schemas.CardCreate,
updated: schemas.CardUpdate,
session: AsyncSession = Depends(db.get_async_session),
user: models.User = Depends(auth.is_user),
):
Expand All @@ -64,7 +64,7 @@ async def update_card(

if not card:
raise HTTPException(status_code=404, detail="Card not found")
for key, value in updated.model_dump().items():
for key, value in updated.model_dump(exclude_unset=True).items():
if getattr(card, key) != value:
setattr(card, key, value)
await session.commit()
Expand All @@ -90,3 +90,27 @@ async def delete_card(
await session.delete(card)
await session.commit()
return {"detail": "Card deleted"}


@router.patch("/cards/{card_id}")
async def patch_card(
card_id: int,
body: schemas.CardPatch,
session: AsyncSession = Depends(db.get_async_session),
user: models.User = Depends(auth.is_user),
):
stmt = (
select(models.Card)
.where(models.Card.id == card_id, models.Card.user_id == user.id)
.limit(1)
)
result = await session.execute(stmt)
card = result.scalar_one_or_none()
if not card:
raise HTTPException(status_code=404, detail="Card not found")
for key, value in body.model_dump(exclude_unset=True).items():
if getattr(card, key) != value:
setattr(card, key, value)
await session.commit()
await session.refresh(card)
return card
6 changes: 5 additions & 1 deletion backend/app/db/models/card.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from app.helpers import now
from .base import Base
from sqlalchemy import Integer, String, Text, DateTime, ForeignKey, text
from sqlalchemy import Integer, String, Text, DateTime, ForeignKey, text, Boolean
from sqlalchemy.orm import relationship, mapped_column, Mapped
from sqlalchemy.sql import func
from datetime import datetime
Expand All @@ -20,6 +20,10 @@ class Card(Base):
name: Mapped[str] = mapped_column(String, nullable=False)
description: Mapped[Optional[Text]] = mapped_column(Text, nullable=True)
color: Mapped[Optional[str]] = mapped_column(String, nullable=True)
is_favorite: Mapped[bool] = mapped_column(
Boolean, server_default=text("FALSE"), default=False, nullable=False
)
used_at: Mapped[bool] = mapped_column(DateTime, nullable=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id"), nullable=False
)
Expand Down
2 changes: 1 addition & 1 deletion backend/app/schemas/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from .user import User, UserCreate, UserUpdate
from .auth import TokenResponse, RefreshRequest, RevokeRequest
from .card import CardBase, CardCreate, CardUpdate, Card
from .card import CardBase, CardCreate, CardUpdate, Card, CardPatch
from .password_recovery import PasswordRecoveryCodeBody, PasswordRecoverySubmitBody
from .version import Version
from .settings import (
Expand Down
34 changes: 33 additions & 1 deletion backend/app/schemas/card.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from pydantic import BaseModel, field_validator, ConfigDict
from typing import Optional
from datetime import datetime, timezone
import re


Expand All @@ -9,6 +10,7 @@ class CardBase(BaseModel):
name: str
description: Optional[str] = None
color: Optional[str] = None
is_favorite: Optional[bool] = None

@field_validator("color")
@classmethod
Expand All @@ -28,7 +30,37 @@ class CardUpdate(CardBase):
pass


class CardPatch(BaseModel):
code: Optional[str] = None
code_type: Optional[str] = None
name: Optional[str] = None
description: Optional[str] = None
color: Optional[str] = None
is_favorite: Optional[bool] = None
used_at: Optional[datetime] = None

@field_validator("used_at")
@classmethod
def to_utc(cls, v: datetime) -> datetime:
if not v:
return v
if v.tzinfo:
return v.astimezone(timezone.utc)
return v.replace(tzinfo=None)

@field_validator("color")
@classmethod
def validate_color(cls, v: str) -> str | None:
if not v:
return None
if not re.search(r"^#(?:[0-9a-fA-F]{3}){1,2}$", v):
raise ValueError("The card color must match hex color string e.g. #ff00ff")
return v


class Card(CardBase):
id: int

used_at: Optional[datetime]
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
32 changes: 32 additions & 0 deletions frontend/public/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@
"NAME": "Name",
"DESCRIPTION": "Description",
"COLOR": "Color",
"IS_FAVORITE": "Favorite",
"USED_AT": "Used at",
"CREATED_AT": "Created at",
"UPDATED_AT": "Updated at",
"CODE_FORMAT_WARN": "The app now uses different names for code types. Save the card with the new code type.",
"SCAN": {
"PERMISSION_ERROR": "Access to video device is denied or feature is not supported.",
Expand Down Expand Up @@ -156,5 +160,33 @@
"IMAGE_VERSION": "Docker image version",
"EXAMPLES": "Supported codes examples",
"CHANGELOG": "Changelog"
},
"SORT_FILTER": {
"TITLE": "Sorting and Filtering",
"SORT_BY": "Sort by",
"SORT_DIR": "Direction",
"SORT_DIRS": {
"ASC": "Ascending",
"DESC": "Descending"
},
"FILTER_BY": "Filter by",
"CRITERIA": "Criteria",
"VALUE": "Value",
"ADD_FILTER": "Add filter",
"BOOL_LABEL": {
"TRUE": "Yes",
"FALSE": "No"
},
"CRITERIAS": {
"LIKE": "Contains",
"EQUALS": "Equals",
"NOT_EQUALS": "Not equals",
"NULL": "Is empty",
"NOT_NULL": "Is not empty",
"GREATER": "Greater than",
"LESS": "Less than",
"GREATER_OR_EQUALS": "Greater or equal",
"LESS_OR_EQUALS": "Less or equal"
}
}
}
32 changes: 32 additions & 0 deletions frontend/public/i18n/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@
"NAME": "Nom",
"DESCRIPTION": "Description",
"COLOR": "Couleur",
"IS_FAVORITE": "Favori",
"USED_AT": "Date d'utilisation",
"CREATED_AT": "Date de création",
"UPDATED_AT": "Date de modification",
"CODE_FORMAT_WARN": "L'application utilise désormais des noms différents pour les types de codes. Enregistrez la carte avec le nouveau type de code.",
"SCAN": {
"PERMISSION_ERROR": "L'accès au périphérique vidéo est refusé ou la fonctionnalité n'est pas prise en charge.",
Expand Down Expand Up @@ -156,5 +160,33 @@
"IMAGE_VERSION": "Version de l'image Docker",
"EXAMPLES": "Exemples de codes pris en charge",
"CHANGELOG": "journal des modifications"
},
"SORT_FILTER": {
"TITLE": "Tri et filtrage",
"SORT_BY": "Trier par",
"SORT_DIR": "Direction",
"SORT_DIRS": {
"ASC": "Croissant",
"DESC": "Décroissant"
},
"FILTER_BY": "Filtrer par",
"CRITERIA": "Critère",
"VALUE": "Valeur",
"ADD_FILTER": "Ajouter un filtre",
"BOOL_LABEL": {
"TRUE": "Oui",
"FALSE": "Non"
},
"CRITERIAS": {
"LIKE": "Contient",
"EQUALS": "Égal à",
"NOT_EQUALS": "Différent de",
"NULL": "Est vide",
"NOT_NULL": "N'est pas vide",
"GREATER": "Supérieur à",
"LESS": "Inférieur à",
"GREATER_OR_EQUALS": "Supérieur ou égal à",
"LESS_OR_EQUALS": "Inférieur ou égal à"
}
}
}
32 changes: 32 additions & 0 deletions frontend/public/i18n/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@
"NAME": "Наименование",
"DESCRIPTION": "Описание",
"COLOR": "Цвет",
"IS_FAVORITE": "Избранное",
"USED_AT": "Дата использования",
"CREATED_AT": "Дата создания",
"UPDATED_AT": "Дата изменения",
"CODE_FORMAT_WARN": "Теперь приложение использует другие наименования для типов кодов. Сохраните карточку с новым типом кода.",
"SCAN": {
"PERMISSION_ERROR": "Доступ к видеоустройству запрещен или функция не поддерживается.",
Expand Down Expand Up @@ -156,5 +160,33 @@
"IMAGE_VERSION": "Версия Docker-образа",
"EXAMPLES": "Примеры поддерживаемых кодов",
"CHANGELOG": "Журнал изменений"
},
"SORT_FILTER": {
"TITLE": "Сортировка и фильтрация",
"SORT_BY": "Сортировать по",
"SORT_DIR": "Направление",
"SORT_DIRS": {
"ASC": "По возрастанию",
"DESC": "По убыванию"
},
"FILTER_BY": "Фильтровать по",
"CRITERIA": "Критерий",
"VALUE": "Значение",
"ADD_FILTER": "Добавить фильтр",
"BOOL_LABEL": {
"TRUE": "Да",
"FALSE": "Нет"
},
"CRITERIAS": {
"LIKE": "Включает",
"EQUALS": "Равно",
"NOT_EQUALS": "Не равно",
"NULL": "Пустое значение",
"NOT_NULL": "Не пустое значение",
"GREATER": "Больше",
"LESS": "Меньше",
"GREATER_OR_EQUALS": "Больше или равно",
"LESS_OR_EQUALS": "Меньше или равно"
}
}
}
2 changes: 2 additions & 0 deletions frontend/src/app/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { UserEffects } from './entities/user/state/user.effects';
import { userInitializer } from './core/app-initializers/user-initializer';
import { localeInitializer } from './core/app-initializers/locale-initializer';
import { appInitializer } from './core/app-initializers/app-initializer';
import { provideNativeDateAdapter } from '@angular/material/core';

export const appConfig: ApplicationConfig = {
providers: [
Expand All @@ -47,6 +48,7 @@ export const appConfig: ApplicationConfig = {
provideEffects([AppEffects, AuthEffects, UserEffects]),
provideStoreDevtools({ maxAge: 25, logOnly: !isDevMode() }),
provideHttpClient(withInterceptors([getTokenInterceptor])),
provideNativeDateAdapter(),
importProvidersFrom(
TranslateModule.forRoot({
loader: {
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/app/app.consts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@ export enum ELocalStorageKey {
* Flag indicationg that app loaded after an update.
*/
AFTER_UPDATE = 'cardholder-after-update',
/**
* Last sorting model
*/
CARD_SORTING = 'cardholder-card-sorting',
/**
* Last filters model
*/
CARD_FILTERS = 'cardholder-card-filters',
}
/**
* Enum of useful regexp
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/app/entities/cards/cards-api.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,8 @@ export class CardApiService extends BaseApiService<'cards'> {
list(): Observable<ICard[]> {
return this.httpClient.get<ICard[]>(`${this.basePath}`);
}

patch(id: number, body: Partial<ICardBase>): Observable<ICard> {
return this.httpClient.patch<ICard>(`${this.basePath}/${id}`, body);
}
}
18 changes: 18 additions & 0 deletions frontend/src/app/entities/cards/cards-interface.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,28 @@
import { TypeToString } from 'src/app/shared/types/type-to-string';

export interface ICardBase {
code: string;
code_type: string;
name: string;
description: string;
color: string;
is_favorite?: boolean;
used_at?: string;
}
export interface ICard extends ICardBase {
id: number;
created_at: string;
updated_at: string;
}
export const ECardFieldType: { [K in keyof ICard]: TypeToString<ICard[K]> } = {
id: 'number',
code: 'string',
code_type: 'string',
name: 'string',
description: 'string',
color: 'string',
is_favorite: 'boolean',
used_at: 'date',
created_at: 'date',
updated_at: 'date',
};
3 changes: 3 additions & 0 deletions frontend/src/app/entities/cards/state/cards.actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,8 @@ export const CardsActions = createActionGroup({
'set form': props<{ form: ICardBase }>(),
'save card': emptyProps(),
'exit card': emptyProps(),
patchListItem: props<{ id: number; body: Partial<ICardBase> }>(),
patchListItemSuccess: props<{ card: ICard }>(),
patchListItemError: props<{ error: HttpErrorResponse }>(),
},
});
Loading