Coverage for seedboxsync/cli/commands/cmd_user.py: 90%

83 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-26 17:14 +0000

1# 

2# Copyright (C) 2015-2026 Guillaume Kulakowski <guillaume@kulakowski.fr> 

3# 

4# For the full copyright and license information, please view the LICENSE 

5# file that was distributed with this source code. 

6# 

7"""All commands related to the users operations & management.""" 

8 

9import click 

10from peewee import fn 

11from werkzeug.security import generate_password_hash 

12from seedboxsync.cli import Context, group, pass_context 

13from seedboxsync.core.database.models import User 

14 

15 

16@group("user", help="User operations & management for SeedboxSync frontend.") # type: ignore[untyped-decorator] 

17@pass_context 

18def cli(ctx: Context) -> None: 

19 """Empty function for Click sub commands.""" 

20 

21 

22@cli.command("list", help="List users.") # type: ignore[untyped-decorator] 

23@click.option("-n", "--number", type=int, default=10, help="Number of torrents to display.") 

24@click.option("-s", "--search", help="Term to search.") 

25@pass_context 

26def list_user(ctx: Context, number: int, search: str) -> None: 

27 """ 

28 List registered users from the database. 

29 

30 Queries user accounts with optional text filtering on usernames and renders 

31 a formatted table containing user metadata. 

32 

33 Args: 

34 ctx (Context): The CLI application context. 

35 number (int): Maximum number of users to display. Defaults to 10. 

36 search (str): Optional search term to filter users by username. 

37 """ 

38 # Build "where" expression 

39 conditions = [] 

40 if search: 

41 conditions.append(User.username.contains(search)) 

42 

43 # DB query 

44 query = ( 

45 User.select( 

46 User.id, 

47 User.username, 

48 User.email, 

49 User.origin, 

50 fn.short_datetime(User.created), 

51 fn.short_datetime(User.last_login), 

52 ) 

53 .limit(number) 

54 .order_by(User.id.desc()) 

55 ) 

56 

57 # if "where" expression 

58 if conditions: 

59 query = query.where(*conditions) 

60 data = query.dicts() 

61 

62 click.echo( 

63 ctx.render( 

64 reversed(data), 

65 headers={"id": "Id", "username": "Username", "email": "Email", "origin": "Origin", "created": "Created", "last_login": "Last Login"}, 

66 ) 

67 ) 

68 

69 

70@cli.command("delete", help="Delete a user.") # type: ignore[untyped-decorator] 

71@click.option("--id", type=int, required=True, help="ID of the user to delete.") 

72@click.option("-y", "--yes", is_flag=True, help="Confirm deletion without prompting.") 

73@pass_context 

74def delete(ctx: Context, id: int, yes: bool) -> None: # noqa: A002 

75 """ 

76 Delete a user by their unique database identifier. 

77 

78 Args: 

79 ctx (Context): The CLI application context. 

80 id (int): Database identifier of the user to remove. 

81 yes (bool): Skip confirmation prompt if set to True. 

82 """ 

83 try: 

84 user = User.get_by_id(id) 

85 except User.DoesNotExist: # pyright: ignore [reportAttributeAccessIssue] 

86 click.secho(f"Error: User with ID {id} does not exist.", fg="red", err=True) 

87 return 

88 

89 if not yes and not click.confirm(f"Are you sure you want to delete user '{user.username}' (ID: {user.id})?"): 

90 click.echo("Operation canceled.") 

91 return 

92 

93 try: 

94 username = user.username 

95 user.delete_instance() 

96 click.secho(f"User '{username}' (ID: {id}) deleted successfully.", fg="green") 

97 except Exception as e: 

98 click.secho(f"Error: Failed to delete user {id}: {e}", fg="red", err=True) 

99 

100 

101@cli.command("add", help="Add a new user.") # type: ignore[untyped-decorator] 

102@click.option("-u", "--username", prompt="Username", help="Username for the new account.") 

103@click.option("-e", "--email", prompt="Email address", help="Unique email address.") 

104@click.option("-p", "--password", prompt=True, hide_input=True, confirmation_prompt=True, help="Password for the user account.") 

105@pass_context 

106def add(ctx: Context, username: str, email: str, password: str) -> None: 

107 """ 

108 Create a new user account in the database. 

109 

110 Accepts account details via command-line options or interactive prompts, 

111 hashes the password, and stores the user record. 

112 

113 Args: 

114 ctx (Context): The CLI application context. 

115 username (str): Unique username for the account. 

116 email (str): Unique email address. 

117 password (str): Plain-text password to hash and store. 

118 """ 

119 # Verify if user already exists before attempting insertion 

120 if User.get_or_none((User.username == username) | (User.email == email)): 

121 click.secho( 

122 f"Error: A user with username '{username}' or email '{email}' already exists.", 

123 fg="red", 

124 err=True, 

125 ) 

126 return 

127 

128 try: 

129 user = User.create( 

130 username=username, 

131 email=email, 

132 password=generate_password_hash(password), 

133 ) 

134 click.secho(f"User '{user.username}' (ID: {user.id}) created successfully.", fg="green") 

135 except Exception as e: 

136 click.secho(f"Error: Failed to create user: {e}", fg="red", err=True) 

137 

138 

139@cli.command("edit", help="Edit an existing user.") # type: ignore[untyped-decorator] 

140@click.option("--id", type=int, required=True, help="ID of the user to edit.") 

141@click.option("-u", "--username", help="New username for the account.") 

142@click.option("-e", "--email", help="New email address.") 

143@click.option("-p", "--password", help="New password for the account.") 

144@pass_context 

145def edit(ctx: Context, id: int, username: str | None, email: str | None, password: str | None) -> None: # noqa: A002 

146 """ 

147 Update details for an existing user account. 

148 

149 Fetches the user by ID and updates only the provided fields. Password 

150 updates are automatically hashed before saving. 

151 

152 Args: 

153 ctx (Context): The CLI application context. 

154 id (int): Database identifier of the user to edit. 

155 username (str | None): New username to set. 

156 email (str | None): New email address to set. 

157 password (str | None): New plain-text password to hash and update. 

158 """ 

159 try: 

160 user = User.get_by_id(id) 

161 except User.DoesNotExist: # pyright: ignore [reportAttributeAccessIssue] 

162 click.secho(f"Error: User with ID {id} does not exist.", fg="red", err=True) 

163 return 

164 

165 # Check unique constraint collisions if username or email is being updated 

166 if username and username != user.username: 

167 if User.get_or_none(User.username == username): 

168 click.secho(f"Error: Username '{username}' is already taken.", fg="red", err=True) 

169 return 

170 user.username = username 

171 

172 if email and email != user.email: 

173 if User.get_or_none(User.email == email): 

174 click.secho(f"Error: Email '{email}' is already taken.", fg="red", err=True) 

175 return 

176 user.email = email 

177 

178 if password: 

179 user.password = generate_password_hash(password) 

180 

181 try: 

182 user.save() 

183 click.secho(f"User '{user.username}' (ID: {user.id}) updated successfully.", fg="green") 

184 except Exception as e: 

185 click.secho(f"Error: Failed to update user {id}: {e}", fg="red", err=True)