import sys import pwd from PySide6.QtCore import Qt, QTime from PySide6.QtWidgets import ( QApplication, QCheckBox, QDialog, QDialogButtonBox, QFormLayout, QFrame, QGridLayout, QHBoxLayout, QLabel, QLineEdit, QMainWindow, QMessageBox, QPushButton, QProgressBar, QScrollArea, QSpinBox, QStackedWidget, QTimeEdit, QVBoxLayout, QWidget, ) from .client import Database, DatabaseError # ============================================================ # Linux users # ============================================================ def linux_user_exists(username): try: pwd.getpwnam(username) return True except KeyError: return False # ============================================================ # Helpers # ============================================================ # SQLite strftime('%w') numbering: # # Sunday = 0 # Monday = 1 # Tuesday = 2 # Wednesday = 3 # Thursday = 4 # Friday = 5 # Saturday = 6 WEEKDAYS = [ (0, "Sunday"), (1, "Monday"), (2, "Tuesday"), (3, "Wednesday"), (4, "Thursday"), (5, "Friday"), (6, "Saturday"), ] def format_seconds(seconds): seconds = max( int(seconds), 0, ) hours = seconds // 3600 minutes = (seconds % 3600) // 60 if hours: return f"{hours}h {minutes:02d}m" return f"{minutes}m" # ============================================================ # User Card # ============================================================ class UserCard(QFrame): def __init__( self, user, edit_callback, toggle_callback, parent=None, ): super().__init__(parent) self.user_id = user["id"] self.setObjectName("UserCard") layout = QVBoxLayout(self) layout.setContentsMargins( 20, 18, 20, 18, ) layout.setSpacing(12) # ---------------------------------------------------- # Header # ---------------------------------------------------- header = QHBoxLayout() username = QLabel( user["username"] ) username.setObjectName( "UserName" ) header.addWidget(username) header.addStretch() status = QLabel( "Enabled" if user["enabled"] else "Disabled" ) status.setObjectName( "StatusEnabled" if user["enabled"] else "StatusDisabled" ) header.addWidget(status) layout.addLayout(header) # ---------------------------------------------------- # Remaining time # ---------------------------------------------------- remaining, allowance = ( Database.get_remaining_time( self.user_id ) ) today_label = QLabel( "Today's allowance" ) today_label.setObjectName( "SecondaryText" ) layout.addWidget(today_label) time_label = QLabel( f"{format_seconds(remaining)} remaining" ) time_label.setObjectName( "TimeRemaining" ) layout.addWidget(time_label) progress = QProgressBar() progress.setTextVisible(False) progress.setMinimum(0) progress.setMaximum( max(allowance, 1) ) progress.setValue( min( remaining, allowance, ) ) layout.addWidget(progress) # ---------------------------------------------------- # Buttons # ---------------------------------------------------- buttons = QHBoxLayout() manage_button = QPushButton( "Manage" ) manage_button.clicked.connect( lambda: edit_callback( self.user_id ) ) buttons.addWidget( manage_button ) toggle_text = ( "Disable" if user["enabled"] else "Enable" ) toggle_button = QPushButton( toggle_text ) toggle_button.clicked.connect( lambda: toggle_callback( self.user_id, not bool( user["enabled"] ), ) ) buttons.addWidget( toggle_button ) layout.addLayout(buttons) # ============================================================ # Dashboard # ============================================================ class DashboardPage(QWidget): def __init__( self, edit_callback, toggle_callback, parent=None, ): super().__init__(parent) self.edit_callback = edit_callback self.toggle_callback = toggle_callback self.layout = QVBoxLayout(self) self.layout.setContentsMargins( 30, 30, 30, 30, ) self.layout.setSpacing(20) title = QLabel( "Dashboard" ) title.setObjectName( "PageTitle" ) self.layout.addWidget(title) self.subtitle = QLabel() self.subtitle.setObjectName( "SecondaryText" ) self.layout.addWidget( self.subtitle ) self.scroll = QScrollArea() self.scroll.setWidgetResizable( True ) self.scroll.setFrameShape( QFrame.NoFrame ) self.container = QWidget() self.cards_layout = QVBoxLayout( self.container ) self.cards_layout.setContentsMargins( 0, 0, 0, 0, ) self.cards_layout.setSpacing( 15 ) self.scroll.setWidget( self.container ) self.layout.addWidget( self.scroll ) self.refresh() def refresh(self): while self.cards_layout.count(): item = self.cards_layout.takeAt(0) widget = item.widget() if widget: widget.deleteLater() users = Database.get_users() self.subtitle.setText( f"{len(users)} managed user" + ( "" if len(users) == 1 else "s" ) ) if not users: empty = QLabel( "No users are currently managed." ) empty.setObjectName( "EmptyText" ) empty.setAlignment( Qt.AlignCenter ) self.cards_layout.addWidget( empty ) else: for user in users: card = UserCard( user, edit_callback=self.edit_callback, toggle_callback=self.toggle_callback, ) self.cards_layout.addWidget( card ) self.cards_layout.addStretch() # ============================================================ # Manage User Dialog # ============================================================ class UserDialog(QDialog): def __init__( self, user=None, parent=None, ): super().__init__(parent) self.user = user if user: self.setWindowTitle( f"Manage User: " f"{user['username']}" ) else: self.setWindowTitle( "Add User" ) self.setMinimumWidth(720) main_layout = QVBoxLayout(self) main_layout.setContentsMargins( 25, 25, 25, 25, ) main_layout.setSpacing(18) # ---------------------------------------------------- # User information # ---------------------------------------------------- form = QFormLayout() self.username_input = QLineEdit() if user: self.username_input.setText( user["username"] ) self.username_input.setReadOnly( True ) form.addRow( "Linux username:", self.username_input, ) self.enabled_checkbox = QCheckBox( "Enable user" ) self.enabled_checkbox.setChecked( bool( user["enabled"] ) if user else True ) form.addRow( "", self.enabled_checkbox, ) main_layout.addLayout( form ) # ---------------------------------------------------- # Daily allowance # ---------------------------------------------------- allowance_title = QLabel( "Daily Allowance" ) allowance_title.setObjectName( "SectionTitle" ) main_layout.addWidget( allowance_title ) allowance_description = QLabel( "Maximum amount of computer time " "the user can consume each day." ) allowance_description.setObjectName( "SecondaryText" ) allowance_description.setWordWrap( True ) main_layout.addWidget( allowance_description ) existing_allowances = ( Database.get_allowances( user["id"] ) if user else {} ) self.day_controls = {} allowance_grid = QGridLayout() allowance_grid.setHorizontalSpacing( 15 ) allowance_grid.setVerticalSpacing( 8 ) allowance_grid.addWidget( QLabel("Day"), 0, 0, ) allowance_grid.addWidget( QLabel("Hours"), 0, 1, ) allowance_grid.addWidget( QLabel("Minutes"), 0, 2, ) for row, ( weekday, name, ) in enumerate( WEEKDAYS, start=1, ): allowance_grid.addWidget( QLabel(name), row, 0, ) hours = QSpinBox() hours.setRange( 0, 24, ) minutes = QSpinBox() minutes.setRange( 0, 59, ) seconds = existing_allowances.get( weekday, 0, ) hours.setValue( seconds // 3600 ) minutes.setValue( (seconds % 3600) // 60 ) allowance_grid.addWidget( hours, row, 1, ) allowance_grid.addWidget( minutes, row, 2, ) self.day_controls[ weekday ] = ( hours, minutes, ) main_layout.addLayout( allowance_grid ) # ---------------------------------------------------- # Access windows # ---------------------------------------------------- window_title = QLabel( "Access Windows" ) window_title.setObjectName( "SectionTitle" ) main_layout.addWidget( window_title ) window_description = QLabel( "Define when the user is allowed to " "use the computer. Disable a day " "to prevent access on that day." ) window_description.setObjectName( "SecondaryText" ) window_description.setWordWrap( True ) main_layout.addWidget( window_description ) existing_windows = ( Database.get_access_windows( user["id"] ) if user else {} ) self.window_controls = {} windows_grid = QGridLayout() windows_grid.setHorizontalSpacing( 15 ) windows_grid.setVerticalSpacing( 8 ) windows_grid.addWidget( QLabel("Day"), 0, 0, ) windows_grid.addWidget( QLabel("Allowed"), 0, 1, ) windows_grid.addWidget( QLabel("Start"), 0, 2, ) windows_grid.addWidget( QLabel("End"), 0, 3, ) for row, ( weekday, name, ) in enumerate( WEEKDAYS, start=1, ): windows_grid.addWidget( QLabel(name), row, 0, ) enabled = QCheckBox() start_time = QTimeEdit() start_time.setDisplayFormat( "HH:mm" ) start_time.setTime( QTime(16, 0) ) end_time = QTimeEdit() end_time.setDisplayFormat( "HH:mm" ) end_time.setTime( QTime(21, 0) ) existing = existing_windows.get( weekday, [] ) if existing: start_minute, end_minute = ( existing[0] ) enabled.setChecked( True ) start_time.setTime( QTime( start_minute // 60, start_minute % 60, ) ) end_time.setTime( QTime( end_minute // 60, end_minute % 60, ) ) else: enabled.setChecked( False ) start_time.setEnabled( enabled.isChecked() ) end_time.setEnabled( enabled.isChecked() ) enabled.toggled.connect( start_time.setEnabled ) enabled.toggled.connect( end_time.setEnabled ) windows_grid.addWidget( enabled, row, 1, ) windows_grid.addWidget( start_time, row, 2, ) windows_grid.addWidget( end_time, row, 3, ) self.window_controls[ weekday ] = ( enabled, start_time, end_time, ) main_layout.addLayout( windows_grid ) # ---------------------------------------------------- # Extra time # ---------------------------------------------------- if user: extra_title = QLabel( "Add Extra Time" ) extra_title.setObjectName( "SectionTitle" ) main_layout.addWidget( extra_title ) extra_description = QLabel( "Add temporary time without changing " "the normal daily allowance." ) extra_description.setObjectName( "SecondaryText" ) extra_description.setWordWrap( True ) main_layout.addWidget( extra_description ) extra_row = QHBoxLayout() self.extra_hours = QSpinBox() self.extra_hours.setRange( 0, 24, ) self.extra_hours.setSuffix( " h" ) self.extra_minutes = QSpinBox() self.extra_minutes.setRange( 0, 59, ) self.extra_minutes.setSuffix( " min" ) add_time_button = QPushButton( "Add Time" ) add_time_button.clicked.connect( self.add_extra_time ) extra_row.addWidget( self.extra_hours ) extra_row.addWidget( self.extra_minutes ) extra_row.addWidget( add_time_button ) extra_row.addStretch() main_layout.addLayout( extra_row ) # ---------------------------------------------------- # Dialog buttons # ---------------------------------------------------- buttons = QDialogButtonBox( QDialogButtonBox.Save | QDialogButtonBox.Cancel ) buttons.accepted.connect( self.save_user ) buttons.rejected.connect( self.reject ) main_layout.addWidget( buttons ) def add_extra_time(self): hours = self.extra_hours.value() minutes = self.extra_minutes.value() seconds = ( hours * 3600 + minutes * 60 ) if seconds <= 0: QMessageBox.warning( self, "Invalid Time", "Please enter an amount of time " "greater than zero.", ) return try: Database.add_temporary_time( self.user["id"], seconds, ) self.extra_hours.setValue( 0 ) self.extra_minutes.setValue( 0 ) QMessageBox.information( self, "Time Added", f"Added {format_seconds(seconds)} " f"to {self.user['username']}.", ) except Exception as exc: QMessageBox.critical( self, "Error", f"Could not add time:\n\n{exc}", ) def save_user(self): username = ( self.username_input .text() .strip() ) if not username: QMessageBox.warning( self, "Missing Username", "Enter a Linux username.", ) return if ( not self.user and not linux_user_exists( username ) ): QMessageBox.warning( self, "User Not Found", f"The Linux user " f"'{username}' does not exist.", ) return # ---------------------------------------------------- # Allowances # ---------------------------------------------------- allowances = {} for weekday, controls in ( self.day_controls.items() ): hours, minutes = controls seconds = ( hours.value() * 3600 + minutes.value() * 60 ) allowances[ weekday ] = seconds # ---------------------------------------------------- # Access windows # ---------------------------------------------------- access_windows = {} for weekday, controls in ( self.window_controls.items() ): enabled, start_time, end_time = ( controls ) if not enabled.isChecked(): continue start = start_time.time() end = end_time.time() start_minute = ( start.hour() * 60 + start.minute() ) end_minute = ( end.hour() * 60 + end.minute() ) if start_minute == end_minute: QMessageBox.warning( self, "Invalid Access Window", "Start and end time cannot " "be the same.", ) return access_windows[ weekday ] = [ ( start_minute, end_minute, ) ] # ---------------------------------------------------- # Save # ---------------------------------------------------- try: if self.user: Database.update_user( self.user["id"], self.enabled_checkbox.isChecked(), allowances, access_windows, ) else: Database.create_user( username, self.enabled_checkbox.isChecked(), allowances, access_windows, ) except DatabaseError: QMessageBox.warning( self, "User Already Exists", "This user is already managed.", ) return except Exception as exc: QMessageBox.critical( self, "Error", f"Could not save the user:\n\n{exc}", ) return self.accept() # ============================================================ # Users Page # ============================================================ class UsersPage(QWidget): def __init__(self, parent=None): super().__init__(parent) layout = QVBoxLayout(self) layout.setContentsMargins( 30, 30, 30, 30, ) layout.setSpacing(20) header = QHBoxLayout() title = QLabel( "Users" ) title.setObjectName( "PageTitle" ) header.addWidget(title) header.addStretch() add_button = QPushButton( "+ Add User" ) add_button.clicked.connect( lambda: self.window().add_user() ) header.addWidget( add_button ) layout.addLayout( header ) description = QLabel( "Manage Linux users and configure " "their parental-control limits." ) description.setObjectName( "SecondaryText" ) layout.addWidget( description ) self.scroll = QScrollArea() self.scroll.setWidgetResizable( True ) self.scroll.setFrameShape( QFrame.NoFrame ) self.container = QWidget() self.users_layout = QVBoxLayout( self.container ) self.users_layout.setContentsMargins( 0, 0, 0, 0, ) self.users_layout.setSpacing( 12 ) self.scroll.setWidget( self.container ) layout.addWidget( self.scroll ) self.refresh() def refresh(self): while self.users_layout.count(): item = self.users_layout.takeAt(0) widget = item.widget() if widget: widget.deleteLater() users = Database.get_users() for user in users: row = QFrame() row.setObjectName( "UserRow" ) row_layout = QHBoxLayout( row ) username = QLabel( user["username"] ) username.setObjectName( "UserName" ) row_layout.addWidget( username ) row_layout.addStretch() status = QLabel( "Enabled" if user["enabled"] else "Disabled" ) row_layout.addWidget( status ) manage = QPushButton( "Manage User" ) manage.clicked.connect( lambda checked=False, user_id=user["id"]: self.window().edit_user( user_id ) ) row_layout.addWidget( manage ) delete = QPushButton( "Delete" ) delete.clicked.connect( lambda checked=False, user_id=user["id"]: self.window().delete_user( user_id ) ) row_layout.addWidget( delete ) self.users_layout.addWidget( row ) self.users_layout.addStretch() # ============================================================ # Main Window # ============================================================ class MainWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle( "Linux Parental Control" ) self.resize( 1100, 750, ) self.pages = QStackedWidget() self.dashboard = DashboardPage( edit_callback=self.edit_user, toggle_callback=self.toggle_user, ) self.users = UsersPage() self.pages.addWidget( self.dashboard ) self.pages.addWidget( self.users ) self.create_sidebar() # -------------------------------------------------------- # Sidebar # -------------------------------------------------------- def create_sidebar(self): central = QWidget() main_layout = QHBoxLayout( central ) main_layout.setContentsMargins( 0, 0, 0, 0, ) main_layout.setSpacing( 0 ) sidebar = QFrame() sidebar.setObjectName( "Sidebar" ) sidebar.setFixedWidth( 220 ) sidebar_layout = QVBoxLayout( sidebar ) sidebar_layout.setContentsMargins( 15, 20, 15, 20, ) sidebar_layout.setSpacing( 8 ) title = QLabel( "Parental Control" ) title.setObjectName( "SidebarTitle" ) sidebar_layout.addWidget( title ) sidebar_layout.addSpacing( 20 ) dashboard_button = QPushButton( "Dashboard" ) dashboard_button.clicked.connect( lambda: self.show_page(0) ) sidebar_layout.addWidget( dashboard_button ) users_button = QPushButton( "Users" ) users_button.clicked.connect( lambda: self.show_page(1) ) sidebar_layout.addWidget( users_button ) sidebar_layout.addStretch() refresh_button = QPushButton( "Refresh" ) refresh_button.clicked.connect( self.refresh_all ) sidebar_layout.addWidget( refresh_button ) main_layout.addWidget( sidebar ) main_layout.addWidget( self.pages ) self.setCentralWidget( central ) def show_page(self, index): self.pages.setCurrentIndex( index ) # -------------------------------------------------------- # Add user # -------------------------------------------------------- def add_user(self): dialog = UserDialog( parent=self ) if dialog.exec(): self.refresh_all() # -------------------------------------------------------- # Manage user # -------------------------------------------------------- def edit_user(self, user_id): user = Database.get_user( user_id ) if not user: QMessageBox.warning( self, "User Not Found", "The selected user no longer exists.", ) return dialog = UserDialog( user=user, parent=self, ) if dialog.exec(): self.refresh_all() # -------------------------------------------------------- # Enable / Disable # -------------------------------------------------------- def toggle_user( self, user_id, enabled, ): user = Database.get_user( user_id ) if not user: return action = ( "enable" if enabled else "disable" ) reply = QMessageBox.question( self, f"{action.title()} User", f"Are you sure you want to " f"{action} user " f"\"{user['username']}\"?", QMessageBox.Yes | QMessageBox.No, ) if reply != QMessageBox.Yes: return try: Database.set_enabled( user_id, enabled, ) except Exception as exc: QMessageBox.critical( self, "Error", f"Could not update the user:\n\n{exc}", ) return self.refresh_all() # -------------------------------------------------------- # Delete user # -------------------------------------------------------- def delete_user(self, user_id): user = Database.get_user( user_id ) if not user: return reply = QMessageBox.question( self, "Delete Managed User", f"Remove " f"\"{user['username']}\" " f"from parental control?\n\n" f"This will NOT delete the Linux account.", QMessageBox.Yes | QMessageBox.No, ) if reply != QMessageBox.Yes: return try: Database.delete_user( user_id ) except Exception as exc: QMessageBox.critical( self, "Error", f"Could not delete the user:\n\n{exc}", ) return self.refresh_all() # -------------------------------------------------------- # Refresh # -------------------------------------------------------- def refresh_all(self): self.dashboard.refresh() self.users.refresh() # ============================================================ # Theme # ============================================================ def apply_theme(app): app.setStyleSheet( """ QWidget { font-family: "Noto Sans"; font-size: 14px; } QMainWindow { background: #f5f5f5; } QFrame#Sidebar { background: #202124; } QLabel#SidebarTitle { color: white; font-size: 20px; font-weight: bold; } QPushButton { padding: 9px 14px; border-radius: 7px; border: 1px solid #cccccc; background: white; } QPushButton:hover { background: #eeeeee; } QPushButton:pressed { background: #dddddd; } QFrame#Sidebar QPushButton { color: white; background: transparent; border: none; text-align: left; padding: 12px; } QFrame#Sidebar QPushButton:hover { background: #303134; } QLabel#PageTitle { font-size: 28px; font-weight: bold; } QLabel#SectionTitle { font-size: 18px; font-weight: bold; } QLabel#UserName { font-size: 18px; font-weight: bold; } QLabel#SecondaryText { color: #666666; } QLabel#TimeRemaining { font-size: 22px; font-weight: bold; } QLabel#StatusEnabled { color: #188038; font-weight: bold; } QLabel#StatusDisabled { color: #d93025; font-weight: bold; } QLabel#EmptyText { color: #777777; font-size: 16px; padding: 40px; } QFrame#UserCard { background: white; border: 1px solid #dddddd; border-radius: 12px; } QFrame#UserRow { background: white; border: 1px solid #dddddd; border-radius: 8px; } QProgressBar { border: none; border-radius: 5px; background: #e5e5e5; height: 10px; } QProgressBar::chunk { border-radius: 5px; background: #4f7cff; } QLineEdit, QSpinBox, QTimeEdit { padding: 7px; border: 1px solid #cccccc; border-radius: 6px; background: white; } QCheckBox { spacing: 8px; } QScrollArea { background: transparent; } """ ) # ============================================================ # Main # ============================================================ def main(): app = QApplication(sys.argv) app.setApplicationName( "Linux Parental Control" ) apply_theme(app) window = MainWindow() window.show() sys.exit( app.exec() ) if __name__ == "__main__": main()