diff --git a/backend/main.py b/backend/main.py index 7a4f18b..67b47a0 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,11 +1,12 @@ from contextlib import asynccontextmanager -from fastapi import FastAPI, Depends, HTTPException, Response, status +from fastapi import FastAPI, Depends, HTTPException, Response, status, Query from fastapi.middleware.cors import CORSMiddleware -from sqlalchemy import select +from sqlalchemy import select, func, desc from sqlalchemy.ext.asyncio import AsyncSession +from pydantic import BaseModel from database import engine, get_db, Base -from models import Counter, User +from models import Counter, User, SumRecord, CounterRecord from auth import ( ACCESS_TOKEN_EXPIRE_MINUTES, COOKIE_NAME, @@ -129,10 +130,175 @@ async def get_counter( @app.post("/api/counter/increment") async def increment_counter( db: AsyncSession = Depends(get_db), - _current_user: User = Depends(get_current_user), + current_user: User = Depends(get_current_user), ): counter = await get_or_create_counter(db) + value_before = counter.value counter.value += 1 + + record = CounterRecord( + user_id=current_user.id, + value_before=value_before, + value_after=counter.value, + ) + db.add(record) await db.commit() return {"value": counter.value} + +# Sum endpoints +class SumRequest(BaseModel): + a: float + b: float + + +class SumResponse(BaseModel): + a: float + b: float + result: float + + +@app.post("/api/sum", response_model=SumResponse) +async def calculate_sum( + data: SumRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + result = data.a + data.b + record = SumRecord( + user_id=current_user.id, + a=data.a, + b=data.b, + result=result, + ) + db.add(record) + await db.commit() + return SumResponse(a=data.a, b=data.b, result=result) + + +# Audit endpoints +from datetime import datetime +from typing import List + + +class CounterRecordResponse(BaseModel): + id: int + user_email: str + value_before: int + value_after: int + created_at: datetime + + +class SumRecordResponse(BaseModel): + id: int + user_email: str + a: float + b: float + result: float + created_at: datetime + + +class PaginatedCounterRecords(BaseModel): + records: List[CounterRecordResponse] + total: int + page: int + per_page: int + total_pages: int + + +class PaginatedSumRecords(BaseModel): + records: List[SumRecordResponse] + total: int + page: int + per_page: int + total_pages: int + + +@app.get("/api/audit/counter", response_model=PaginatedCounterRecords) +async def get_counter_records( + page: int = Query(1, ge=1), + per_page: int = Query(10, ge=1, le=100), + db: AsyncSession = Depends(get_db), + _current_user: User = Depends(get_current_user), +): + # Get total count + count_result = await db.execute(select(func.count(CounterRecord.id))) + total = count_result.scalar() or 0 + total_pages = (total + per_page - 1) // per_page if total > 0 else 1 + + # Get paginated records with user email + offset = (page - 1) * per_page + query = ( + select(CounterRecord, User.email) + .join(User, CounterRecord.user_id == User.id) + .order_by(desc(CounterRecord.created_at)) + .offset(offset) + .limit(per_page) + ) + result = await db.execute(query) + rows = result.all() + + records = [ + CounterRecordResponse( + id=record.id, + user_email=email, + value_before=record.value_before, + value_after=record.value_after, + created_at=record.created_at, + ) + for record, email in rows + ] + + return PaginatedCounterRecords( + records=records, + total=total, + page=page, + per_page=per_page, + total_pages=total_pages, + ) + + +@app.get("/api/audit/sum", response_model=PaginatedSumRecords) +async def get_sum_records( + page: int = Query(1, ge=1), + per_page: int = Query(10, ge=1, le=100), + db: AsyncSession = Depends(get_db), + _current_user: User = Depends(get_current_user), +): + # Get total count + count_result = await db.execute(select(func.count(SumRecord.id))) + total = count_result.scalar() or 0 + total_pages = (total + per_page - 1) // per_page if total > 0 else 1 + + # Get paginated records with user email + offset = (page - 1) * per_page + query = ( + select(SumRecord, User.email) + .join(User, SumRecord.user_id == User.id) + .order_by(desc(SumRecord.created_at)) + .offset(offset) + .limit(per_page) + ) + result = await db.execute(query) + rows = result.all() + + records = [ + SumRecordResponse( + id=record.id, + user_email=email, + a=record.a, + b=record.b, + result=record.result, + created_at=record.created_at, + ) + for record, email in rows + ] + + return PaginatedSumRecords( + records=records, + total=total, + page=page, + per_page=per_page, + total_pages=total_pages, + ) + diff --git a/backend/models.py b/backend/models.py index 7251bf8..02c052a 100644 --- a/backend/models.py +++ b/backend/models.py @@ -1,4 +1,5 @@ -from sqlalchemy import Integer, String +from datetime import datetime +from sqlalchemy import Integer, String, Float, DateTime, ForeignKey from sqlalchemy.orm import Mapped, mapped_column from database import Base @@ -17,3 +18,24 @@ class User(Base): email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True) hashed_password: Mapped[str] = mapped_column(String(255), nullable=False) + +class SumRecord(Base): + __tablename__ = "sum_records" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False, index=True) + a: Mapped[float] = mapped_column(Float, nullable=False) + b: Mapped[float] = mapped_column(Float, nullable=False) + result: Mapped[float] = mapped_column(Float, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + +class CounterRecord(Base): + __tablename__ = "counter_records" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False, index=True) + value_before: Mapped[int] = mapped_column(Integer, nullable=False) + value_after: Mapped[int] = mapped_column(Integer, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + diff --git a/frontend/app/audit/page.tsx b/frontend/app/audit/page.tsx new file mode 100644 index 0000000..dfe714d --- /dev/null +++ b/frontend/app/audit/page.tsx @@ -0,0 +1,428 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { useAuth } from "../auth-context"; +import { API_URL } from "../config"; + +interface CounterRecord { + id: number; + user_email: string; + value_before: number; + value_after: number; + created_at: string; +} + +interface SumRecord { + id: number; + user_email: string; + a: number; + b: number; + result: number; + created_at: string; +} + +interface PaginatedResponse { + records: T[]; + total: number; + page: number; + per_page: number; + total_pages: number; +} + +export default function AuditPage() { + const [counterData, setCounterData] = useState | null>(null); + const [sumData, setSumData] = useState | null>(null); + const [counterPage, setCounterPage] = useState(1); + const [sumPage, setSumPage] = useState(1); + const { user, isLoading, logout } = useAuth(); + const router = useRouter(); + + useEffect(() => { + if (!isLoading && !user) { + router.push("/login"); + } + }, [isLoading, user, router]); + + useEffect(() => { + if (user) { + fetchCounterRecords(counterPage); + } + }, [user, counterPage]); + + useEffect(() => { + if (user) { + fetchSumRecords(sumPage); + } + }, [user, sumPage]); + + const fetchCounterRecords = async (page: number) => { + try { + const res = await fetch(`${API_URL}/api/audit/counter?page=${page}&per_page=10`, { + credentials: "include", + }); + const data = await res.json(); + setCounterData(data); + } catch { + setCounterData(null); + } + }; + + const fetchSumRecords = async (page: number) => { + try { + const res = await fetch(`${API_URL}/api/audit/sum?page=${page}&per_page=10`, { + credentials: "include", + }); + const data = await res.json(); + setSumData(data); + } catch { + setSumData(null); + } + }; + + const handleLogout = async () => { + await logout(); + router.push("/login"); + }; + + const formatDate = (dateStr: string) => { + return new Date(dateStr).toLocaleString(); + }; + + if (isLoading) { + return ( +
+
Loading...
+
+ ); + } + + if (!user) { + return null; + } + + return ( +
+
+
+ Counter + + Sum + + Audit +
+
+ {user.email} + +
+
+ +
+
+ {/* Counter Records Table */} +
+
+

Counter Activity

+ + {counterData?.total ?? 0} records + +
+
+ + + + + + + + + + + {counterData?.records.map((record) => ( + + + + + + + ))} + {(!counterData || counterData.records.length === 0) && ( + + + + )} + +
UserBeforeAfterDate
{record.user_email}{record.value_before}{record.value_after}{formatDate(record.created_at)}
No records yet
+
+ {counterData && counterData.total_pages > 1 && ( +
+ + + {counterPage} / {counterData.total_pages} + + +
+ )} +
+ + {/* Sum Records Table */} +
+
+

Sum Activity

+ + {sumData?.total ?? 0} records + +
+
+ + + + + + + + + + + + {sumData?.records.map((record) => ( + + + + + + + + ))} + {(!sumData || sumData.records.length === 0) && ( + + + + )} + +
UserABResultDate
{record.user_email}{record.a}{record.b}{record.result}{formatDate(record.created_at)}
No records yet
+
+ {sumData && sumData.total_pages > 1 && ( +
+ + + {sumPage} / {sumData.total_pages} + + +
+ )} +
+
+
+
+ ); +} + +const styles: Record = { + main: { + minHeight: "100vh", + background: "linear-gradient(135deg, #0f0f23 0%, #1a1a3e 50%, #2d1b4e 100%)", + display: "flex", + flexDirection: "column", + }, + loader: { + flex: 1, + display: "flex", + alignItems: "center", + justifyContent: "center", + fontFamily: "'DM Sans', system-ui, sans-serif", + color: "rgba(255, 255, 255, 0.5)", + fontSize: "1.125rem", + }, + header: { + padding: "1.5rem 2rem", + borderBottom: "1px solid rgba(255, 255, 255, 0.06)", + display: "flex", + justifyContent: "space-between", + alignItems: "center", + }, + nav: { + display: "flex", + alignItems: "center", + gap: "0.75rem", + }, + navLink: { + fontFamily: "'DM Sans', system-ui, sans-serif", + color: "rgba(255, 255, 255, 0.5)", + fontSize: "0.875rem", + textDecoration: "none", + transition: "color 0.2s", + }, + navDivider: { + color: "rgba(255, 255, 255, 0.2)", + fontSize: "0.75rem", + }, + navCurrent: { + fontFamily: "'DM Sans', system-ui, sans-serif", + color: "#a78bfa", + fontSize: "0.875rem", + fontWeight: 600, + }, + userInfo: { + display: "flex", + alignItems: "center", + gap: "1rem", + }, + userEmail: { + fontFamily: "'DM Sans', system-ui, sans-serif", + color: "rgba(255, 255, 255, 0.6)", + fontSize: "0.875rem", + }, + logoutBtn: { + fontFamily: "'DM Sans', system-ui, sans-serif", + padding: "0.5rem 1rem", + fontSize: "0.875rem", + fontWeight: 500, + background: "rgba(255, 255, 255, 0.05)", + color: "rgba(255, 255, 255, 0.7)", + border: "1px solid rgba(255, 255, 255, 0.1)", + borderRadius: "8px", + cursor: "pointer", + transition: "all 0.2s", + }, + content: { + flex: 1, + padding: "2rem", + overflowY: "auto", + }, + tablesContainer: { + display: "flex", + flexDirection: "column", + gap: "2rem", + maxWidth: "1200px", + margin: "0 auto", + }, + tableCard: { + background: "rgba(255, 255, 255, 0.03)", + backdropFilter: "blur(10px)", + border: "1px solid rgba(255, 255, 255, 0.08)", + borderRadius: "20px", + padding: "1.5rem", + boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.5)", + }, + tableHeader: { + display: "flex", + justifyContent: "space-between", + alignItems: "center", + marginBottom: "1rem", + }, + tableTitle: { + fontFamily: "'Instrument Serif', Georgia, serif", + fontSize: "1.5rem", + fontWeight: 400, + color: "#fff", + margin: 0, + }, + totalCount: { + fontFamily: "'DM Sans', system-ui, sans-serif", + fontSize: "0.875rem", + color: "rgba(255, 255, 255, 0.4)", + }, + tableWrapper: { + overflowX: "auto", + }, + table: { + width: "100%", + borderCollapse: "collapse", + fontFamily: "'DM Sans', system-ui, sans-serif", + }, + th: { + textAlign: "left", + padding: "0.75rem 1rem", + fontSize: "0.75rem", + fontWeight: 600, + color: "rgba(255, 255, 255, 0.4)", + textTransform: "uppercase", + letterSpacing: "0.05em", + borderBottom: "1px solid rgba(255, 255, 255, 0.08)", + }, + tr: { + borderBottom: "1px solid rgba(255, 255, 255, 0.04)", + }, + td: { + padding: "0.875rem 1rem", + fontSize: "0.875rem", + color: "rgba(255, 255, 255, 0.7)", + }, + tdNum: { + padding: "0.875rem 1rem", + fontSize: "0.875rem", + color: "rgba(255, 255, 255, 0.9)", + fontFamily: "'DM Sans', monospace", + }, + tdResult: { + padding: "0.875rem 1rem", + fontSize: "0.875rem", + color: "#a78bfa", + fontWeight: 600, + fontFamily: "'DM Sans', monospace", + }, + tdDate: { + padding: "0.875rem 1rem", + fontSize: "0.75rem", + color: "rgba(255, 255, 255, 0.4)", + }, + emptyRow: { + padding: "2rem 1rem", + textAlign: "center", + color: "rgba(255, 255, 255, 0.3)", + fontSize: "0.875rem", + }, + pagination: { + display: "flex", + justifyContent: "center", + alignItems: "center", + gap: "1rem", + marginTop: "1rem", + paddingTop: "1rem", + borderTop: "1px solid rgba(255, 255, 255, 0.06)", + }, + pageBtn: { + fontFamily: "'DM Sans', system-ui, sans-serif", + padding: "0.5rem 1rem", + fontSize: "1rem", + background: "rgba(255, 255, 255, 0.05)", + color: "rgba(255, 255, 255, 0.7)", + border: "1px solid rgba(255, 255, 255, 0.1)", + borderRadius: "8px", + cursor: "pointer", + transition: "all 0.2s", + }, + pageInfo: { + fontFamily: "'DM Sans', system-ui, sans-serif", + fontSize: "0.875rem", + color: "rgba(255, 255, 255, 0.5)", + }, +}; + diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index aff961b..a75465c 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -56,6 +56,13 @@ export default function Home() { return (
+
+ Counter + + Sum + + Audit +
{user.email} +
+
+ +
+
+ Sum Calculator + + {!showResult ? ( +
+
+
+ setA(e.target.value)} + placeholder="0" + style={styles.input} + aria-label="First number" + /> +
+ + +
+ setB(e.target.value)} + placeholder="0" + style={styles.input} + aria-label="Second number" + /> +
+
+ +
+ ) : ( +
+
+ {parseFloat(a) || 0} + + + {parseFloat(b) || 0} + = +
+

{result}

+ +
+ )} +
+
+
+ ); +} + +const styles: Record = { + main: { + minHeight: "100vh", + background: "linear-gradient(135deg, #0f0f23 0%, #1a1a3e 50%, #2d1b4e 100%)", + display: "flex", + flexDirection: "column", + }, + loader: { + flex: 1, + display: "flex", + alignItems: "center", + justifyContent: "center", + fontFamily: "'DM Sans', system-ui, sans-serif", + color: "rgba(255, 255, 255, 0.5)", + fontSize: "1.125rem", + }, + header: { + padding: "1.5rem 2rem", + borderBottom: "1px solid rgba(255, 255, 255, 0.06)", + display: "flex", + justifyContent: "space-between", + alignItems: "center", + }, + nav: { + display: "flex", + alignItems: "center", + gap: "0.75rem", + }, + navLink: { + fontFamily: "'DM Sans', system-ui, sans-serif", + color: "rgba(255, 255, 255, 0.5)", + fontSize: "0.875rem", + textDecoration: "none", + transition: "color 0.2s", + }, + navDivider: { + color: "rgba(255, 255, 255, 0.2)", + fontSize: "0.75rem", + }, + navCurrent: { + fontFamily: "'DM Sans', system-ui, sans-serif", + color: "#a78bfa", + fontSize: "0.875rem", + fontWeight: 600, + }, + userInfo: { + display: "flex", + alignItems: "center", + gap: "1rem", + }, + userEmail: { + fontFamily: "'DM Sans', system-ui, sans-serif", + color: "rgba(255, 255, 255, 0.6)", + fontSize: "0.875rem", + }, + logoutBtn: { + fontFamily: "'DM Sans', system-ui, sans-serif", + padding: "0.5rem 1rem", + fontSize: "0.875rem", + fontWeight: 500, + background: "rgba(255, 255, 255, 0.05)", + color: "rgba(255, 255, 255, 0.7)", + border: "1px solid rgba(255, 255, 255, 0.1)", + borderRadius: "8px", + cursor: "pointer", + transition: "all 0.2s", + }, + content: { + flex: 1, + display: "flex", + alignItems: "center", + justifyContent: "center", + padding: "2rem", + }, + card: { + background: "rgba(255, 255, 255, 0.03)", + backdropFilter: "blur(10px)", + border: "1px solid rgba(255, 255, 255, 0.08)", + borderRadius: "32px", + padding: "3rem 4rem", + textAlign: "center", + boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.5)", + minWidth: "400px", + }, + label: { + fontFamily: "'DM Sans', system-ui, sans-serif", + display: "block", + color: "rgba(255, 255, 255, 0.4)", + fontSize: "0.875rem", + textTransform: "uppercase", + letterSpacing: "0.1em", + marginBottom: "2rem", + }, + inputSection: { + display: "flex", + flexDirection: "column", + alignItems: "center", + gap: "2rem", + }, + inputRow: { + display: "flex", + alignItems: "center", + gap: "1rem", + }, + inputWrapper: { + position: "relative", + }, + input: { + fontFamily: "'Instrument Serif', Georgia, serif", + fontSize: "3rem", + fontWeight: 400, + width: "120px", + padding: "0.75rem 1rem", + textAlign: "center", + background: "rgba(255, 255, 255, 0.05)", + border: "2px solid rgba(255, 255, 255, 0.1)", + borderRadius: "16px", + color: "#fff", + outline: "none", + transition: "border-color 0.2s, box-shadow 0.2s", + }, + operator: { + fontFamily: "'Instrument Serif', Georgia, serif", + fontSize: "3rem", + color: "#a78bfa", + fontWeight: 400, + }, + sumBtn: { + fontFamily: "'DM Sans', system-ui, sans-serif", + padding: "1rem 2.5rem", + fontSize: "1.125rem", + fontWeight: 600, + background: "linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)", + color: "#fff", + border: "none", + borderRadius: "16px", + cursor: "pointer", + display: "inline-flex", + alignItems: "center", + gap: "0.5rem", + transition: "transform 0.2s, box-shadow 0.2s", + boxShadow: "0 4px 14px rgba(99, 102, 241, 0.4)", + }, + equalsIcon: { + fontSize: "1.5rem", + fontWeight: 400, + }, + resultSection: { + display: "flex", + flexDirection: "column", + alignItems: "center", + }, + equation: { + display: "flex", + alignItems: "center", + gap: "0.5rem", + marginBottom: "0.5rem", + }, + equationNum: { + fontFamily: "'DM Sans', system-ui, sans-serif", + fontSize: "1.25rem", + color: "rgba(255, 255, 255, 0.6)", + }, + equationOp: { + fontFamily: "'DM Sans', system-ui, sans-serif", + fontSize: "1.25rem", + color: "rgba(139, 92, 246, 0.8)", + }, + result: { + fontFamily: "'Instrument Serif', Georgia, serif", + fontSize: "7rem", + fontWeight: 400, + color: "#fff", + margin: 0, + lineHeight: 1, + background: "linear-gradient(135deg, #fff 0%, #a78bfa 100%)", + WebkitBackgroundClip: "text", + WebkitTextFillColor: "transparent", + backgroundClip: "text", + }, + resetBtn: { + fontFamily: "'DM Sans', system-ui, sans-serif", + marginTop: "2rem", + padding: "0.875rem 2rem", + fontSize: "1rem", + fontWeight: 500, + background: "rgba(255, 255, 255, 0.05)", + color: "rgba(255, 255, 255, 0.7)", + border: "1px solid rgba(255, 255, 255, 0.1)", + borderRadius: "12px", + cursor: "pointer", + display: "inline-flex", + alignItems: "center", + gap: "0.5rem", + transition: "all 0.2s", + }, + resetIcon: { + fontSize: "1.25rem", + }, +}; +