diff --git a/scripts/db_migrations/008_foreignkey_refactoring.py b/scripts/db_migrations/008_foreignkey_refactoring.py index 0727757..3f09f99 100644 --- a/scripts/db_migrations/008_foreignkey_refactoring.py +++ b/scripts/db_migrations/008_foreignkey_refactoring.py @@ -306,8 +306,9 @@ def migrate_sqlite(): print("\n=== Phase 4: Update Foreign Key references ===") - # 4a. Update UserOrganization.organization to reference new User IDs - cursor.execute("SELECT id, organization FROM userorganization") + # 4a. Update UserOrganization.organization_id to reference new User IDs + # NOTE: Peewee ForeignKeyField creates columns with _id suffix + cursor.execute("SELECT id, organization_id FROM userorganization") memberships = cursor.fetchall() for membership_id, old_org_id in memberships: @@ -319,7 +320,7 @@ def migrate_sqlite(): if result: new_user_id = result[0] cursor.execute( - "UPDATE userorganization SET organization = ? WHERE id = ?", + "UPDATE userorganization SET organization_id = ? WHERE id = ?", (new_user_id, membership_id), ) @@ -609,10 +610,11 @@ def migrate_postgres(): print("\n=== Phase 4: Update Foreign Key references ===") - # 4a. Update UserOrganization.organization to reference new User IDs + # 4a. Update UserOrganization.organization_id to reference new User IDs + # NOTE: Peewee ForeignKeyField creates columns with _id suffix cursor.execute( - "UPDATE userorganization SET organization = m.new_user_id " - "FROM _org_id_mapping m WHERE userorganization.organization = m.old_org_id" + "UPDATE userorganization SET organization_id = m.new_user_id " + "FROM _org_id_mapping m WHERE userorganization.organization_id = m.old_org_id" ) affected = cursor.rowcount db.commit() diff --git a/src/kohaku-hub-admin/src/components.d.ts b/src/kohaku-hub-admin/src/components.d.ts index 8632f92..899fbac 100644 --- a/src/kohaku-hub-admin/src/components.d.ts +++ b/src/kohaku-hub-admin/src/components.d.ts @@ -29,9 +29,12 @@ declare module 'vue' { ElOption: typeof import('element-plus/es')['ElOption'] ElPagination: typeof import('element-plus/es')['ElPagination'] ElProgress: typeof import('element-plus/es')['ElProgress'] + ElRadio: typeof import('element-plus/es')['ElRadio'] ElRadioButton: typeof import('element-plus/es')['ElRadioButton'] ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup'] ElSelect: typeof import('element-plus/es')['ElSelect'] + ElSkeleton: typeof import('element-plus/es')['ElSkeleton'] + ElStatistic: typeof import('element-plus/es')['ElStatistic'] ElSwitch: typeof import('element-plus/es')['ElSwitch'] ElTable: typeof import('element-plus/es')['ElTable'] ElTableColumn: typeof import('element-plus/es')['ElTableColumn'] diff --git a/src/kohaku-hub-ui/src/pages/organizations/new.vue b/src/kohaku-hub-ui/src/pages/organizations/new.vue index cfa6f29..a43a7ea 100644 --- a/src/kohaku-hub-ui/src/pages/organizations/new.vue +++ b/src/kohaku-hub-ui/src/pages/organizations/new.vue @@ -30,7 +30,7 @@ />
- Only lowercase letters, numbers, and hyphens are allowed + Letters (case-insensitive), numbers, hyphens and underscores allowed
@@ -120,8 +120,8 @@ const rules = { trigger: "blur", }, { - pattern: /^[a-z0-9-]+$/, - message: "Only lowercase letters, numbers, and hyphens allowed", + pattern: /^[a-zA-Z0-9_-]+$/, + message: "Only letters, numbers, hyphens and underscores allowed", trigger: "blur", }, { @@ -133,7 +133,8 @@ const rules = { }; function validateOrgName() { - form.value.name = form.value.name.toLowerCase().replace(/[^a-z0-9-]/g, ""); + // Remove invalid characters but preserve case (case-insensitive matching done server-side) + form.value.name = form.value.name.replace(/[^a-zA-Z0-9_-]/g, ""); } function handleCancel() { diff --git a/src/kohakuhub/api/admin.py b/src/kohakuhub/api/admin.py index dc7295c..e8a0f73 100644 --- a/src/kohakuhub/api/admin.py +++ b/src/kohakuhub/api/admin.py @@ -2,6 +2,7 @@ import asyncio import hashlib +import json import secrets from datetime import datetime, timedelta, timezone @@ -13,12 +14,13 @@ from pydantic import BaseModel from kohakuhub.async_utils import run_in_s3_executor from kohakuhub.config import cfg from kohakuhub.db import ( - db, Commit, File, + Invitation, LFSObjectHistory, Repository, User, + db, ) from kohakuhub.db_operations import ( check_invitation_available, @@ -395,8 +397,6 @@ async def create_register_invitation_admin( Returns: Created invitation token and link """ - import json - # Validate role if org_id provided if request.org_id: if request.role not in ["visitor", "member", "admin"]: @@ -475,8 +475,6 @@ async def list_invitations_admin( Returns: List of invitations with details """ - from kohakuhub.db import Invitation - query = Invitation.select() if action: diff --git a/src/kohakuhub/api/avatar.py b/src/kohakuhub/api/avatar.py index dc6d56b..6df39a5 100644 --- a/src/kohakuhub/api/avatar.py +++ b/src/kohakuhub/api/avatar.py @@ -11,8 +11,9 @@ from kohakuhub.db import User from kohakuhub.db_operations import ( get_organization, get_user_by_username, - update_user, + get_user_organization, update_organization, + update_user, ) from kohakuhub.logger import get_logger from kohakuhub.auth.dependencies import get_current_user, get_optional_user @@ -249,8 +250,6 @@ async def upload_org_avatar( Raises: HTTPException: If not authorized or invalid image """ - from kohakuhub.db_operations import get_user_organization - org = get_organization(org_name) if not org: raise HTTPException(404, detail="Organization not found") @@ -354,8 +353,6 @@ async def delete_org_avatar( Returns: Success message """ - from kohakuhub.db_operations import get_user_organization - org = get_organization(org_name) if not org: raise HTTPException(404, detail="Organization not found") diff --git a/src/kohakuhub/api/commit/routers/history.py b/src/kohakuhub/api/commit/routers/history.py index 0a0d2a0..66e4f5b 100644 --- a/src/kohakuhub/api/commit/routers/history.py +++ b/src/kohakuhub/api/commit/routers/history.py @@ -1,15 +1,14 @@ """Commit history API endpoints.""" -from typing import Optional import asyncio import difflib +from typing import Optional from fastapi import APIRouter, Depends, Query -import httpx from kohakuhub.config import cfg -from kohakuhub.db import Repository, User -from kohakuhub.db_operations import get_commit, get_repository, list_commits_by_repo +from kohakuhub.db import User +from kohakuhub.db_operations import get_commit, get_repository from kohakuhub.lakefs_rest_client import get_lakefs_rest_client from kohakuhub.logger import get_logger from kohakuhub.auth.dependencies import get_optional_user @@ -282,8 +281,6 @@ async def get_commit_diff( # Fetch File records for LFS status using repository FK and backref if file_paths: - from kohakuhub.db import File - file_records = { f.path_in_repo: f for f in repo_row.files.select().where( diff --git a/src/kohakuhub/api/commit/routers/operations.py b/src/kohakuhub/api/commit/routers/operations.py index cac71ab..d0b1362 100644 --- a/src/kohakuhub/api/commit/routers/operations.py +++ b/src/kohakuhub/api/commit/routers/operations.py @@ -24,7 +24,7 @@ from kohakuhub.auth.dependencies import get_current_user from kohakuhub.auth.permissions import check_repo_write_permission from kohakuhub.utils.lakefs import get_lakefs_client, lakefs_repo_name from kohakuhub.utils.s3 import get_object_metadata, object_exists -from kohakuhub.api.quota.util import update_namespace_storage +from kohakuhub.api.quota.util import update_namespace_storage, update_repository_storage from kohakuhub.api.repo.utils.gc import run_gc_for_file, track_lfs_object logger = get_logger("FILE") @@ -744,13 +744,19 @@ async def commit( f"GC: Cleaned up {deleted_count} old version(s) of {lfs_info['path']}" ) - # Update storage usage for namespace after successful commit + # Update storage usage for namespace and repository after successful commit try: + # Recalculate repository storage (keeps repo.used_bytes accurate) + await update_repository_storage(repo_row) + logger.debug( + f"Updated repository storage for {repo_id}: {repo_row.used_bytes:,} bytes" + ) + # Check if namespace is organization (User with is_org=True) org = get_organization(namespace) is_org = org is not None - # Recalculate storage usage + # Recalculate namespace storage usage await update_namespace_storage(namespace, is_org) logger.debug( f"Updated storage usage for {'org' if is_org else 'user'} {namespace}" diff --git a/src/kohakuhub/api/settings.py b/src/kohakuhub/api/settings.py index fb8cd68..4054e38 100644 --- a/src/kohakuhub/api/settings.py +++ b/src/kohakuhub/api/settings.py @@ -1,17 +1,19 @@ """User, organization, and repository settings API endpoints.""" +import json from typing import Optional from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, EmailStr -from kohakuhub.db import Repository, User, UserOrganization +from kohakuhub.db import User from kohakuhub.db_operations import ( get_organization, get_repository, get_user_by_email_excluding_id, get_user_by_username, get_user_organization, + list_organization_members, update_organization, update_repository, update_user, @@ -19,8 +21,8 @@ from kohakuhub.db_operations import ( from kohakuhub.logger import get_logger from kohakuhub.auth.dependencies import get_current_user from kohakuhub.auth.permissions import check_repo_delete_permission -from kohakuhub.api.repo.utils.hf import hf_repo_not_found from kohakuhub.api.quota.util import calculate_repository_storage, check_quota +from kohakuhub.api.repo.utils.hf import hf_repo_not_found logger = get_logger("SETTINGS") @@ -83,8 +85,6 @@ async def update_user_settings( update_fields["website"] = req.website if req.social_media is not None: - import json - # Validate social_media structure if not isinstance(req.social_media, dict): raise HTTPException(400, detail="social_media must be a dictionary") @@ -113,8 +113,6 @@ async def get_user_profile(username: str): if not user: raise HTTPException(404, detail="User not found") - import json - # Parse social_media JSON if exists social_media = None if user.social_media: @@ -185,8 +183,6 @@ async def update_organization_settings( update_fields["website"] = req.website if req.social_media is not None: - import json - # Validate social_media structure if not isinstance(req.social_media, dict): raise HTTPException(400, detail="social_media must be a dictionary") @@ -215,8 +211,6 @@ async def get_organization_profile(org_name: str): if not org: raise HTTPException(404, detail="Organization not found") - import json - # Parse social_media JSON if exists social_media = None if org.social_media: @@ -226,8 +220,6 @@ async def get_organization_profile(org_name: str): social_media = None # Count members - from kohakuhub.db_operations import list_organization_members - members = list_organization_members(org) member_count = len(members) diff --git a/src/kohakuhub/api/validation.py b/src/kohakuhub/api/validation.py index bb23341..ff4c684 100644 --- a/src/kohakuhub/api/validation.py +++ b/src/kohakuhub/api/validation.py @@ -73,7 +73,7 @@ async def check_name_availability(req: CheckNameRequest) -> CheckNameResponse: available=False, normalized_name=normalized, conflict_with=f"{req.namespace}/{repo.name}", - message=f"Repository name conflicts with existing repository: {repo.name}", + message=f"Repository name conflicts with existing repository: {repo.name} (case-insensitive)", ) return CheckNameResponse( @@ -100,7 +100,7 @@ async def check_name_availability(req: CheckNameRequest) -> CheckNameResponse: available=False, normalized_name=normalized, conflict_with=user.username, - message=f"Username conflicts with existing user: {user.username}", + message=f"Username conflicts with existing user: {user.username} (case-insensitive)", ) # Check organization name (now unified with User model using normalized_name for efficiency) @@ -113,7 +113,7 @@ async def check_name_availability(req: CheckNameRequest) -> CheckNameResponse: available=False, normalized_name=normalized, conflict_with=existing_org.username, - message=f"Name conflicts with existing organization: {existing_org.username}", + message=f"Name conflicts with existing organization: {existing_org.username} (case-insensitive)", ) return CheckNameResponse( diff --git a/src/kohakuhub/auth/routes.py b/src/kohakuhub/auth/routes.py index 177ea76..d472274 100644 --- a/src/kohakuhub/auth/routes.py +++ b/src/kohakuhub/auth/routes.py @@ -1,6 +1,7 @@ """Authentication API routes.""" import asyncio +import json from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException, Response @@ -8,26 +9,27 @@ from fastapi.responses import RedirectResponse from pydantic import BaseModel, EmailStr from kohakuhub.config import cfg -from kohakuhub.db import EmailVerification, Session, Token, User, db +from kohakuhub.db import Session, Token, User, db from kohakuhub.db_operations import ( + check_invitation_available, create_email_verification, create_session, create_token, create_user, + create_user_organization, delete_email_verification, - delete_session, delete_token, get_email_verification, - get_session, - get_token_by_hash, + get_invitation, get_user_by_email, get_user_by_id, get_user_by_username, list_user_tokens, + mark_invitation_used, update_user, ) from kohakuhub.logger import get_logger -from kohakuhub.auth.dependencies import get_current_user, get_optional_user +from kohakuhub.auth.dependencies import get_current_user from kohakuhub.auth.email import send_verification_email from kohakuhub.auth.utils import ( generate_session_secret, @@ -80,8 +82,6 @@ async def register(req: RegisterRequest, invitation_token: str | None = None): ) # Validate invitation token - from kohakuhub.db_operations import check_invitation_available, get_invitation - invitation = get_invitation(invitation_token) if not invitation: raise HTTPException(400, detail="Invalid invitation token") @@ -134,9 +134,6 @@ async def register(req: RegisterRequest, invitation_token: str | None = None): # If registration used an invitation, mark it and add to org if specified if cfg.auth.invitation_only and invitation_token: - from kohakuhub.db_operations import get_invitation, mark_invitation_used - import json - invitation = get_invitation(invitation_token) if invitation: try: @@ -148,11 +145,6 @@ async def register(req: RegisterRequest, invitation_token: str | None = None): # Add user to organization if specified if org_id: - from kohakuhub.db_operations import ( - create_user_organization, - get_user_by_id, - ) - org = get_user_by_id(org_id) if org: role = params.get("role", "member")