Skip to content

Commit 8e8a0ee

Browse files
Send inactive users notification (#552)
* Add functionality to query notebook apps and count them in the get apps endpoint * add the send inactive user mail reminder endpoint * send the inactive users an email notification
1 parent 5c8e62a commit 8e8a0ee

File tree

4 files changed

+154
-56
lines changed

4 files changed

+154
-56
lines changed

api_docs.yml

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -776,35 +776,84 @@ paths:
776776
500:
777777
description: "Internal Server Error"
778778
"/users/inactive_user_reminder":
779-
post:
779+
get:
780780
tags:
781781
- inactive_user_reminder
782+
summary: "Get inactive users with pagination and optional time filtering"
783+
consumes:
784+
- application/json
785+
produces:
786+
- application/json
782787
parameters:
783788
- in: header
784789
name: Authorization
785790
required: true
786791
type: string
787792
description: "Bearer [token]"
793+
- in: query
794+
name: page
795+
required: false
796+
type: integer
797+
description: "Page number (default: 1)"
798+
- in: query
799+
name: per_page
800+
required: false
801+
type: integer
802+
description: "Results per page (default: 10)"
788803
- in: query
789804
name: value
790-
required: true
805+
required: false
791806
type: integer
792807
description: "Numeric value for the time period"
793808
- in: query
794809
name: unit
795-
required: true
810+
required: false
796811
type: string
797812
enum: [hours, days, months]
798813
description: "Unit of time (hours/days/months)"
814+
responses:
815+
200:
816+
description: "Success - Returns paginated list of inactive users"
817+
400:
818+
description: "Bad request - Invalid parameters"
819+
401:
820+
description: "Unauthorized - Admin access required"
821+
500:
822+
description: "Internal Server Error"
823+
post:
824+
tags:
825+
- inactive_user_reminder
826+
summary: "Send reminder emails to inactive users"
827+
consumes:
828+
- application/json
799829
produces:
800830
- application/json
831+
parameters:
832+
- in: header
833+
name: Authorization
834+
required: true
835+
description: "Bearer [token]"
836+
type: string
837+
- in: body
838+
name: inactive_user_reminder
839+
schema:
840+
type: object
841+
required:
842+
- inactive_users
843+
properties:
844+
inactive_users:
845+
type: array
846+
items:
847+
type: string
848+
description: "User UUID"
801849
responses:
802-
200:
803-
description: "Success"
850+
201:
851+
description: "Success - Emails sent"
804852
400:
805853
description: "Bad request - Invalid parameters"
806854
500:
807855
description: "Internal Server Error"
856+
808857
"/clusters":
809858
post:
810859
tags:

app/controllers/project.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,6 @@ def post(self):
180180
description='Created project Successfully',
181181
a_project=project.id,
182182
a_cluster_id=cluster_id,
183-
184183
)
185184

186185
return dict(status='success', data=dict(project=new_project_data)), 201
@@ -637,20 +636,20 @@ def get(self, user_id):
637636
}
638637

639638
user_projects, errors = project_schema.dumps(projects)
640-
639+
641640
pinned_projects, errs = project_schema.dumps(pinned_projects)
642641

643642
parsed_pinned_projects = json.loads(pinned_projects)
644643

645644
if errors or errs:
646645
return dict(status='fail', message='Internal server error'), 500
647-
648646
return dict(
649647
status='success',
650648
data=dict(
651649
pagination={**pagination_data,
652650
'pinned_count': len(parsed_pinned_projects)},
653651
pinned=parsed_pinned_projects,
652+
654653
projects=json.loads(user_projects),
655654
)
656655
), 200

app/controllers/users.py

Lines changed: 97 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1340,69 +1340,118 @@ def get(self, user_id):
13401340

13411341
class SendInactiveUserMailReminder(Resource):
13421342
@admin_required
1343-
def post(self):
1343+
def get(self):
1344+
user_schema = UserSchema(many=True)
1345+
page = request.args.get('page', 1, type=int)
1346+
per_page = request.args.get('per_page', 10, type=int)
13441347

1345-
parser = reqparse.RequestParser()
1346-
parser.add_argument('value', type=int, required=True,
1347-
help='Time period value is required')
1348-
parser.add_argument('unit', type=str, required=True,
1349-
choices=('hours', 'days', 'months'),
1350-
help='Time unit must be hours, days, or months')
1348+
value = request.args.get('value', type=int)
1349+
unit = request.args.get('unit')
13511350

1352-
args = parser.parse_args()
1353-
value = args['value']
1354-
unit = args['unit'].lower()
1351+
query = User.query.filter(
1352+
User.verified == True,
1353+
User.disabled == False,
1354+
User.admin_disabled == False
1355+
)
1356+
1357+
if value is not None and unit is not None:
1358+
unit = unit.lower()
1359+
if unit not in ('hours', 'days', 'months'):
1360+
return dict(status='fail', message="Unit must be hours, days, or months"), 400
1361+
1362+
now = datetime.now()
1363+
if unit == 'hours':
1364+
lower_threshold = now - timedelta(hours=value)
1365+
upper_threshold = now - timedelta(hours=value-1)
1366+
elif unit == 'days':
1367+
lower_threshold = now - timedelta(days=value)
1368+
upper_threshold = now - timedelta(days=value-1)
1369+
else:
1370+
lower_threshold = now - timedelta(days=value * 30)
1371+
upper_threshold = now - timedelta(days=(value-1) * 30)
1372+
1373+
query = query.filter(
1374+
User.last_seen <= upper_threshold,
1375+
User.last_seen > lower_threshold
1376+
)
13551377

1356-
now = datetime.now()
1357-
if unit == 'hours':
1358-
lower_threshold = now - timedelta(hours=value)
1359-
upper_threshold = now - timedelta(hours=value-1)
1360-
elif unit == 'days':
1361-
lower_threshold = now - timedelta(days=value)
1362-
upper_threshold = now - timedelta(days=value-1)
1363-
else:
1364-
lower_threshold = now - timedelta(days=value * 30)
1365-
upper_threshold = now - timedelta(days=(value-1) * 30)
1378+
query = query.order_by(User.last_seen.desc())
1379+
1380+
paginated = query.paginate(page=page, per_page=per_page, error_out=False)
1381+
users = paginated.items
1382+
1383+
pagination = {
1384+
'total': paginated.total,
1385+
'pages': paginated.pages,
1386+
'page': paginated.page,
1387+
'per_page': paginated.per_page,
1388+
'next': paginated.next_num,
1389+
'prev': paginated.prev_num
1390+
}
1391+
users_data = user_schema.dump(users)
13661392

1367-
1368-
inactive_users = User.query.filter(
1369-
User.last_seen <= upper_threshold,
1370-
User.last_seen > lower_threshold,
1393+
return dict(
1394+
status='success',
1395+
message=f'Found {paginated.total} inactive users',
1396+
data=dict(pagination=pagination, users=users_data)
1397+
), 200
1398+
1399+
@admin_required
1400+
def post(self):
1401+
user_data = request.get_json()
1402+
1403+
if not user_data or 'inactive_users' not in user_data:
1404+
return dict(status='fail', message='List of user UUIDs not provided'), 400
13711405

1372-
User.verified == True,
1373-
User.disabled == False,
1374-
User.admin_disabled == False
1375-
).all()
1406+
inactive_users = user_data['inactive_users']
1407+
1408+
if not isinstance(inactive_users, list):
1409+
return dict(status='fail', message='Invalid format for inactive users'), 400
13761410

1377-
already_notified = set()
1378-
13791411
emails_sent = 0
13801412
errors = []
1413+
now = datetime.now()
13811414

1382-
for user in inactive_users:
1415+
for user_uuid in inactive_users:
13831416
try:
1384-
success = send_inactive_notification_to_user(
1385-
email=user.email,
1386-
name=user.name,
1387-
app=current_app._get_current_object(),
1388-
template="user/inactive_user_reminder.html",
1389-
subject="Checking In: Your Crane Cloud Account",
1390-
date=now.strftime("%m/%d/%Y"),
1391-
is_success_template=True
1392-
)
1393-
1394-
if success:
1395-
emails_sent += 1
1396-
already_notified.add(user.email)
1417+
user = User.query.get(user_uuid)
1418+
if not user:
1419+
errors.append(f"User with UUID {user_uuid} not found")
1420+
continue
1421+
1422+
if user.last_reminder_sent is None or user.last_reminder_sent < (now - timedelta(days=30)):
1423+
success = send_inactive_notification_to_user(
1424+
email=user.email,
1425+
name=user.name,
1426+
app=current_app._get_current_object(),
1427+
template="user/inactive_user_reminder.html",
1428+
subject="We miss you at Crane Cloud",
1429+
date=now.strftime("%m/%d/%Y"),
1430+
is_success_template=True
1431+
)
1432+
1433+
if success:
1434+
emails_sent += 1
1435+
user.last_reminder_sent = now
1436+
db.session.add(user)
1437+
else:
1438+
errors.append(f"Failed to send email to {user.email}")
13971439
else:
1398-
errors.append(f"Failed to send email to {user.email}")
1440+
errors.append(f"Email reminder already sent to {user.email} within the last 30 days")
13991441

14001442
except Exception as e:
1401-
errors.append(f"Error sending email to {user.email}: {str(e)}")
1402-
1443+
errors.append(f"Error processing user {user_uuid}: {str(e)}")
1444+
1445+
try:
1446+
db.session.commit()
1447+
except Exception as e:
1448+
db.session.rollback()
1449+
return dict(status='fail', message=f'Database error: {str(e)}'), 500
1450+
14031451
return dict(
14041452
status='success',
14051453
message=f'Successfully sent {emails_sent} reminder emails',
14061454
total_users_processed=len(inactive_users),
14071455
errors=errors if errors else None
1408-
), 200
1456+
), 201
1457+

app/models/user.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ class User(ModelMixin):
4040
verified = db.Column(db.Boolean, nullable=False, default=False)
4141
date_created = db.Column(db.DateTime, default=db.func.current_timestamp())
4242
last_seen = db.Column(db.DateTime, default=db.func.current_timestamp())
43+
last_reminder_sent = db.Column(db.DateTime, default=db.func.current_timestamp())
4344
projects = db.relationship('Project', backref='owner', lazy=True)
4445
organisation = db.Column(db.String(256), nullable=True, default="")
4546
other_projects = db.relationship('ProjectUser', back_populates='user')

0 commit comments

Comments
 (0)