Update user model migration to set invalid password placeholder; enhance invite management with new endpoints for active invites and improved error handling in group invite creation. Refactor frontend to fetch and display active invite codes.
This commit is contained in:
parent
f2ac73502c
commit
c2aa62fa03
@ -27,7 +27,7 @@ def upgrade() -> None:
|
||||
op.add_column('users', sa.Column('is_verified', sa.Boolean(), nullable=True, server_default=sa.sql.expression.false()))
|
||||
|
||||
# 2. Set default values for existing rows
|
||||
op.execute("UPDATE users SET hashed_password = '' WHERE hashed_password IS NULL")
|
||||
op.execute("UPDATE users SET hashed_password = '$INVALID_PASSWORD_PLACEHOLDER$' WHERE hashed_password IS NULL")
|
||||
op.execute("UPDATE users SET is_active = true WHERE is_active IS NULL")
|
||||
op.execute("UPDATE users SET is_superuser = false WHERE is_superuser IS NULL")
|
||||
op.execute("UPDATE users SET is_verified = false WHERE is_verified IS NULL")
|
||||
|
@ -4,7 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from app.database import get_async_session
|
||||
from app.models import User
|
||||
from app.auth import oauth, fastapi_users
|
||||
from app.auth import oauth, fastapi_users, auth_backend
|
||||
from app.config import settings
|
||||
|
||||
router = APIRouter()
|
||||
@ -36,7 +36,7 @@ async def google_callback(request: Request, db: AsyncSession = Depends(get_async
|
||||
user_to_login = new_user
|
||||
|
||||
# Generate JWT token
|
||||
strategy = fastapi_users._auth_backends[0].get_strategy()
|
||||
strategy = auth_backend.get_strategy()
|
||||
token_response = await strategy.write_token(user_to_login)
|
||||
access_token = token_response["access_token"]
|
||||
refresh_token = token_response.get("refresh_token") # Use .get for safety, though it should be there
|
||||
@ -86,7 +86,7 @@ async def apple_callback(request: Request, db: AsyncSession = Depends(get_async_
|
||||
user_to_login = new_user
|
||||
|
||||
# Generate JWT token
|
||||
strategy = fastapi_users._auth_backends[0].get_strategy()
|
||||
strategy = auth_backend.get_strategy()
|
||||
token_response = await strategy.write_token(user_to_login)
|
||||
access_token = token_response["access_token"]
|
||||
refresh_token = token_response.get("refresh_token") # Use .get for safety
|
||||
|
@ -118,11 +118,57 @@ async def create_group_invite(
|
||||
invite = await crud_invite.create_invite(db=db, group_id=group_id, creator_id=current_user.id)
|
||||
if not invite:
|
||||
logger.error(f"Failed to generate unique invite code for group {group_id}")
|
||||
# This case should ideally be covered by exceptions from create_invite now
|
||||
raise InviteCreationError(group_id)
|
||||
|
||||
logger.info(f"User {current_user.email} created invite code for group {group_id}")
|
||||
try:
|
||||
await db.commit() # Explicit commit before returning
|
||||
logger.info(f"User {current_user.email} created and committed invite code for group {group_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to commit transaction after creating invite for group {group_id}: {e}", exc_info=True)
|
||||
await db.rollback() # Ensure rollback if explicit commit fails
|
||||
# Re-raise to ensure a 500 error is returned
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to save invite: {str(e)}")
|
||||
|
||||
return invite
|
||||
|
||||
@router.get(
|
||||
"/{group_id}/invites",
|
||||
response_model=InviteCodePublic, # Or Optional[InviteCodePublic] if it can be null
|
||||
summary="Get Group Active Invite Code",
|
||||
tags=["Groups", "Invites"]
|
||||
)
|
||||
async def get_group_active_invite(
|
||||
group_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: UserModel = Depends(current_active_user),
|
||||
):
|
||||
"""Retrieves the active invite code for the group. Requires group membership (owner/admin to be stricter later if needed)."""
|
||||
logger.info(f"User {current_user.email} attempting to get active invite for group {group_id}")
|
||||
|
||||
# Permission check: Ensure user is a member of the group to view invite code
|
||||
# Using get_user_role_in_group which also checks membership indirectly
|
||||
user_role = await crud_group.get_user_role_in_group(db, group_id=group_id, user_id=current_user.id)
|
||||
if user_role is None: # Not a member
|
||||
logger.warning(f"Permission denied: User {current_user.email} is not a member of group {group_id} and cannot view invite code.")
|
||||
# More specific error or let GroupPermissionError handle if we want to be generic
|
||||
raise GroupMembershipError(group_id, "view invite code for this group (not a member)")
|
||||
|
||||
# Fetch the active invite for the group
|
||||
invite = await crud_invite.get_active_invite_for_group(db, group_id=group_id)
|
||||
|
||||
if not invite:
|
||||
# This case means no active (non-expired, active=true) invite exists.
|
||||
# The frontend can then prompt to generate one.
|
||||
logger.info(f"No active invite code found for group {group_id} when requested by {current_user.email}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="No active invite code found for this group. Please generate one."
|
||||
)
|
||||
|
||||
logger.info(f"User {current_user.email} retrieved active invite code for group {group_id}")
|
||||
return invite # Pydantic will convert InviteModel to InviteCodePublic
|
||||
|
||||
@router.delete(
|
||||
"/{group_id}/leave",
|
||||
response_model=Message,
|
||||
|
@ -20,68 +20,114 @@ from app.core.exceptions import (
|
||||
# Invite codes should be reasonably unique, but handle potential collision
|
||||
MAX_CODE_GENERATION_ATTEMPTS = 5
|
||||
|
||||
async def create_invite(db: AsyncSession, group_id: int, creator_id: int, expires_in_days: int = 7) -> Optional[InviteModel]:
|
||||
"""Creates a new invite code for a group."""
|
||||
async def deactivate_all_active_invites_for_group(db: AsyncSession, group_id: int):
|
||||
"""Deactivates all currently active invite codes for a specific group."""
|
||||
try:
|
||||
stmt = (
|
||||
select(InviteModel)
|
||||
.where(InviteModel.group_id == group_id, InviteModel.is_active == True)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
active_invites = result.scalars().all()
|
||||
|
||||
if not active_invites:
|
||||
return # No active invites to deactivate
|
||||
|
||||
for invite in active_invites:
|
||||
invite.is_active = False
|
||||
db.add(invite)
|
||||
|
||||
# await db.flush() # Removed: Rely on caller to flush/commit
|
||||
# No explicit commit here, assuming it's part of a larger transaction or caller handles commit.
|
||||
except OperationalError as e:
|
||||
# It's better to let the caller handle rollback or commit based on overall operation success
|
||||
raise DatabaseConnectionError(f"DB connection error deactivating invites for group {group_id}: {str(e)}")
|
||||
except SQLAlchemyError as e:
|
||||
raise DatabaseTransactionError(f"DB transaction error deactivating invites for group {group_id}: {str(e)}")
|
||||
|
||||
async def create_invite(db: AsyncSession, group_id: int, creator_id: int, expires_in_days: int = 365 * 100) -> Optional[InviteModel]: # Default to 100 years
|
||||
"""Creates a new invite code for a group, deactivating any existing active ones for that group first."""
|
||||
|
||||
# Deactivate existing active invites for this group
|
||||
await deactivate_all_active_invites_for_group(db, group_id)
|
||||
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(days=expires_in_days)
|
||||
|
||||
potential_code = None
|
||||
for attempt in range(MAX_CODE_GENERATION_ATTEMPTS):
|
||||
potential_code = secrets.token_urlsafe(16)
|
||||
# Check if an *active* invite with this code already exists (outside main transaction for now)
|
||||
# Ideally, unique constraint on (code, is_active=true) in DB and catch IntegrityError.
|
||||
# This check reduces collision chance before attempting transaction.
|
||||
existing_check_stmt = select(InviteModel.id).where(InviteModel.code == potential_code, InviteModel.is_active == True).limit(1)
|
||||
existing_result = await db.execute(existing_check_stmt)
|
||||
if existing_result.scalar_one_or_none() is None:
|
||||
break # Found a potentially unique code
|
||||
break
|
||||
if attempt == MAX_CODE_GENERATION_ATTEMPTS - 1:
|
||||
raise InviteOperationError("Failed to generate a unique invite code after several attempts.")
|
||||
|
||||
# Removed explicit transaction block here, rely on FastAPI's request-level transaction.
|
||||
# Final check for code collision (less critical now without explicit nested transaction rollback on collision)
|
||||
# but still good to prevent duplicate active codes if possible, though the deactivate step helps.
|
||||
final_check_stmt = select(InviteModel.id).where(InviteModel.code == potential_code, InviteModel.is_active == True).limit(1)
|
||||
final_check_result = await db.execute(final_check_stmt)
|
||||
if final_check_result.scalar_one_or_none() is not None:
|
||||
# This is now more of a rare edge case if deactivate worked and code generation is diverse.
|
||||
# Depending on strictness, could raise an error or just log and proceed,
|
||||
# relying on the previous deactivation to ensure only one is active.
|
||||
# For now, let's raise to be safe, as it implies a very quick collision.
|
||||
raise InviteOperationError("Invite code collision detected just before creation attempt.")
|
||||
|
||||
db_invite = InviteModel(
|
||||
code=potential_code,
|
||||
group_id=group_id,
|
||||
created_by_id=creator_id,
|
||||
expires_at=expires_at,
|
||||
is_active=True
|
||||
)
|
||||
db.add(db_invite)
|
||||
await db.flush() # Flush to get ID for re-fetch and ensure it's in session before potential re-fetch.
|
||||
|
||||
# Re-fetch with relationships
|
||||
stmt = (
|
||||
select(InviteModel)
|
||||
.where(InviteModel.id == db_invite.id)
|
||||
.options(
|
||||
selectinload(InviteModel.group),
|
||||
selectinload(InviteModel.creator)
|
||||
)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
loaded_invite = result.scalar_one_or_none()
|
||||
|
||||
if loaded_invite is None:
|
||||
# This would be an issue, implies flush didn't work or ID was wrong.
|
||||
# The main transaction will rollback if this exception is raised.
|
||||
raise InviteOperationError("Failed to load invite after creation and flush.")
|
||||
|
||||
return loaded_invite
|
||||
# No explicit commit here, FastAPI handles it for the request.
|
||||
|
||||
async def get_active_invite_for_group(db: AsyncSession, group_id: int) -> Optional[InviteModel]:
|
||||
"""Gets the currently active and non-expired invite for a specific group."""
|
||||
now = datetime.now(timezone.utc)
|
||||
try:
|
||||
async with db.begin_nested() if db.in_transaction() else db.begin() as transaction:
|
||||
# Final check within transaction to be absolutely sure before insert
|
||||
final_check_stmt = select(InviteModel.id).where(InviteModel.code == potential_code, InviteModel.is_active == True).limit(1)
|
||||
final_check_result = await db.execute(final_check_stmt)
|
||||
if final_check_result.scalar_one_or_none() is not None:
|
||||
# Extremely unlikely if previous check passed, but handles race condition
|
||||
await transaction.rollback()
|
||||
raise InviteOperationError("Invite code collision detected during transaction.")
|
||||
|
||||
db_invite = InviteModel(
|
||||
code=potential_code,
|
||||
group_id=group_id,
|
||||
created_by_id=creator_id,
|
||||
expires_at=expires_at,
|
||||
is_active=True
|
||||
stmt = (
|
||||
select(InviteModel).where(
|
||||
InviteModel.group_id == group_id,
|
||||
InviteModel.is_active == True,
|
||||
InviteModel.expires_at > now # Still respect expiry, even if very long
|
||||
)
|
||||
db.add(db_invite)
|
||||
await db.flush() # Assigns ID
|
||||
|
||||
# Re-fetch with relationships
|
||||
stmt = (
|
||||
select(InviteModel)
|
||||
.where(InviteModel.id == db_invite.id)
|
||||
.options(
|
||||
selectinload(InviteModel.group),
|
||||
selectinload(InviteModel.creator)
|
||||
)
|
||||
.order_by(InviteModel.created_at.desc()) # Get the most recent one if multiple (should not happen)
|
||||
.limit(1)
|
||||
.options(
|
||||
selectinload(InviteModel.group), # Eager load group
|
||||
selectinload(InviteModel.creator) # Eager load creator
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
loaded_invite = result.scalar_one_or_none()
|
||||
|
||||
if loaded_invite is None:
|
||||
await transaction.rollback()
|
||||
raise InviteOperationError("Failed to load invite after creation.")
|
||||
|
||||
await transaction.commit()
|
||||
return loaded_invite
|
||||
except IntegrityError as e: # Catch if DB unique constraint on code is violated
|
||||
# Rollback handled by context manager
|
||||
raise DatabaseIntegrityError(f"Failed to create invite due to DB integrity: {str(e)}")
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalars().first()
|
||||
except OperationalError as e:
|
||||
raise DatabaseConnectionError(f"DB connection error during invite creation: {str(e)}")
|
||||
raise DatabaseConnectionError(f"DB connection error fetching active invite for group {group_id}: {str(e)}")
|
||||
except SQLAlchemyError as e:
|
||||
raise DatabaseTransactionError(f"DB transaction error during invite creation: {str(e)}")
|
||||
raise DatabaseQueryError(f"DB query error fetching active invite for group {group_id}: {str(e)}")
|
||||
|
||||
async def get_active_invite_by_code(db: AsyncSession, code: str) -> Optional[InviteModel]:
|
||||
"""Gets an active and non-expired invite by its code."""
|
||||
|
@ -51,6 +51,8 @@ export const API_ENDPOINTS = {
|
||||
LISTS: (groupId: string) => `/groups/${groupId}/lists`,
|
||||
MEMBERS: (groupId: string) => `/groups/${groupId}/members`,
|
||||
MEMBER: (groupId: string, userId: string) => `/groups/${groupId}/members/${userId}`,
|
||||
CREATE_INVITE: (groupId: string) => `/groups/${groupId}/invites`,
|
||||
GET_ACTIVE_INVITE: (groupId: string) => `/groups/${groupId}/invites`,
|
||||
LEAVE: (groupId: string) => `/groups/${groupId}/leave`,
|
||||
DELETE: (groupId: string) => `/groups/${groupId}`,
|
||||
SETTINGS: (groupId: string) => `/groups/${groupId}/settings`,
|
||||
|
@ -54,21 +54,24 @@
|
||||
<div class="card-body">
|
||||
<button class="btn btn-secondary" @click="generateInviteCode" :disabled="generatingInvite">
|
||||
<span v-if="generatingInvite" class="spinner-dots-sm" role="status"><span /><span /><span /></span>
|
||||
Generate Invite Code
|
||||
{{ inviteCode ? 'Regenerate Invite Code' : 'Generate Invite Code' }}
|
||||
</button>
|
||||
<div v-if="inviteCode" class="form-group mt-2">
|
||||
<label for="inviteCodeInput" class="form-label">Invite Code:</label>
|
||||
<label for="inviteCodeInput" class="form-label">Current Active Invite Code:</label>
|
||||
<div class="flex items-center">
|
||||
<input id="inviteCodeInput" type="text" :value="inviteCode" class="form-input flex-grow" readonly />
|
||||
<button class="btn btn-neutral btn-icon-only ml-1" @click="copyInviteCodeHandler"
|
||||
aria-label="Copy invite code">
|
||||
<svg class="icon">
|
||||
<use xlink:href="#icon-clipboard"></use>
|
||||
</svg> <!-- Assuming #icon-clipboard is 'content_copy' -->
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="copySuccess" class="form-success-text mt-1">Invite code copied to clipboard!</p>
|
||||
</div>
|
||||
<div v-else class="mt-2">
|
||||
<p class="text-muted">No active invite code. Click the button above to generate one.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -112,6 +115,7 @@ const group = ref<Group | null>(null);
|
||||
const loading = ref(true);
|
||||
const error = ref<string | null>(null);
|
||||
const inviteCode = ref<string | null>(null);
|
||||
const inviteExpiresAt = ref<string | null>(null);
|
||||
const generatingInvite = ref(false);
|
||||
const copySuccess = ref(false);
|
||||
const removingMember = ref<number | null>(null);
|
||||
@ -123,6 +127,33 @@ const { copy, copied, isSupported: clipboardIsSupported } = useClipboard({
|
||||
source: computed(() => inviteCode.value || '')
|
||||
});
|
||||
|
||||
const fetchActiveInviteCode = async () => {
|
||||
if (!groupId.value) return;
|
||||
// Consider adding a loading state for this fetch if needed, e.g., initialInviteCodeLoading
|
||||
try {
|
||||
const response = await apiClient.get(API_ENDPOINTS.GROUPS.GET_ACTIVE_INVITE(String(groupId.value)));
|
||||
if (response.data && response.data.code) {
|
||||
inviteCode.value = response.data.code;
|
||||
inviteExpiresAt.value = response.data.expires_at; // Store expiry
|
||||
} else {
|
||||
inviteCode.value = null; // No active code found
|
||||
inviteExpiresAt.value = null;
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.response && err.response.status === 404) {
|
||||
inviteCode.value = null; // Explicitly set to null on 404
|
||||
inviteExpiresAt.value = null;
|
||||
// Optional: notify user or set a flag to show "generate one" message more prominently
|
||||
console.info('No active invite code found for this group.');
|
||||
} else {
|
||||
const message = err instanceof Error ? err.message : 'Failed to fetch active invite code.';
|
||||
// error.value = message; // This would display a large error banner, might be too much
|
||||
console.error('Error fetching active invite code:', err);
|
||||
notificationStore.addNotification({ message, type: 'error' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const fetchGroupDetails = async () => {
|
||||
if (!groupId.value) return;
|
||||
loading.value = true;
|
||||
@ -138,19 +169,24 @@ const fetchGroupDetails = async () => {
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
// Fetch active invite code after group details are loaded
|
||||
await fetchActiveInviteCode();
|
||||
};
|
||||
|
||||
const generateInviteCode = async () => {
|
||||
if (!groupId.value) return;
|
||||
generatingInvite.value = true;
|
||||
inviteCode.value = null;
|
||||
copySuccess.value = false;
|
||||
try {
|
||||
const response = await apiClient.post(API_ENDPOINTS.INVITES.BASE, {
|
||||
group_id: groupId.value, // Ensure this matches API expectation (string or number)
|
||||
});
|
||||
inviteCode.value = response.data.invite_code;
|
||||
notificationStore.addNotification({ message: 'Invite code generated successfully!', type: 'success' });
|
||||
const response = await apiClient.post(API_ENDPOINTS.GROUPS.CREATE_INVITE(String(groupId.value)));
|
||||
if (response.data && response.data.code) {
|
||||
inviteCode.value = response.data.code;
|
||||
inviteExpiresAt.value = response.data.expires_at; // Update with new expiry
|
||||
notificationStore.addNotification({ message: 'New invite code generated successfully!', type: 'success' });
|
||||
} else {
|
||||
// Should not happen if POST is successful and returns the code
|
||||
throw new Error('New invite code data is invalid.');
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to generate invite code.';
|
||||
console.error('Error generating invite code:', err);
|
||||
|
Loading…
Reference in New Issue
Block a user