diff --git a/models.py b/models.py index f06e69f..c7742b0 100644 --- a/models.py +++ b/models.py @@ -953,15 +953,26 @@ def get_unchecked_report(target_date=None, user_id=None): # One user_id param slot for the user_filter inside the main query user_filter_params = [user_id] if user_id else [] - # The subquery needs: dow_params, (optional user_id for shift_users join) - # Outer query needs: date_params, user_filter_params, date_params - # EXISTS(shift) subquery: dow_params + (user_id if filtering by user) - # We build params carefully to match the f-string placeholders below. + # Fix for Bug #1: MySQL does not allow a derived table (subquery in FROM/JOIN) + # to reference outer-query aliases (e.g. u.id). The previous approach used + # "AND su.user_id = u.id" inside a derived table when user_id=None, which + # MySQL rejects with "Unknown column 'u.id' in 'where clause'". + # + # Solution: replace the derived-table JOIN with an EXISTS correlated subquery + # in the WHERE clause. Correlated subqueries CAN reference outer aliases, + # so u.id is always in scope. This works identically for both the + # single-user and all-users cases. + # + # Param order: + # date_params → {date_expr} in SELECT + # user_filter_params → {user_filter} AND u.id = %s + # dow_params → DAYOFWEEK(%s) in EXISTS + # user_filter_params → su.user_id = u.id or %s in EXISTS (always u.id now) + # date_params → DATE(sc.checked_at) = {date_expr} params = ( date_params # {date_expr} in SELECT + user_filter_params # {user_filter} AND u.id = %s + dow_params # DAYOFWEEK(%s) in shift EXISTS - + (user_id and [user_id] or []) # su.user_id=%s in shift EXISTS + date_params # DATE(sc.checked_at) = {date_expr} ) @@ -975,29 +986,27 @@ def get_unchecked_report(target_date=None, user_id=None): w.url, 'Not Checked' AS status FROM users u - -- Only websites the user was expected to check on this date - JOIN ( - SELECT DISTINCT sw.website_id - FROM shift_websites sw - JOIN shifts s ON s.id = sw.shift_id - JOIN shift_users su ON su.shift_id = s.id - JOIN websites w2 ON w2.id = sw.website_id - WHERE s.is_active = 1 - AND w2.is_active = 1 - AND LOCATE(CAST({dow_expr} AS CHAR), s.days_of_week) > 0 - {"AND su.user_id = %s" if user_id else "AND su.user_id = u.id"} - AND ( - w2.visibility = 'all' - OR EXISTS ( - SELECT 1 FROM website_users wu - WHERE wu.website_id = w2.id AND wu.user_id = su.user_id - ) - ) - ) expected ON 1=1 - JOIN websites w ON w.id = expected.website_id + JOIN websites w ON w.is_active = 1 WHERE u.is_active = 1 AND u.role = 'user' {user_filter} + -- Only include websites the user was expected to check via their shifts + AND EXISTS ( + SELECT 1 + FROM shift_websites sw + JOIN shifts s ON s.id = sw.shift_id + JOIN shift_users su ON su.shift_id = s.id AND su.user_id = u.id + WHERE sw.website_id = w.id + AND s.is_active = 1 + AND LOCATE(CAST({dow_expr} AS CHAR), s.days_of_week) > 0 + AND ( + w.visibility = 'all' + OR EXISTS ( + SELECT 1 FROM website_users wu + WHERE wu.website_id = w.id AND wu.user_id = u.id + ) + ) + ) -- Exclude sites the user DID check on the target date AND NOT EXISTS ( SELECT 1 FROM shift_checks sc @@ -1049,46 +1058,63 @@ def get_summary_report(date_from=None, date_to=None): where_clause = ("WHERE " + " AND ".join(conditions)) if conditions else "" + # Fix for Bug #2: MySQL only_full_group_by rejects referencing sc.checked_at + # (the full timestamp) inside correlated subqueries when only DATE(sc.checked_at) + # appears in the GROUP BY clause. Even though sc.checked_at is functionally + # determined by DATE(sc.checked_at) in intent, MySQL strict mode does not + # infer that relationship automatically. + # + # Solution: pre-aggregate in a CTE (agg) that produces a single, unambiguous + # check_date (DATE) and user_id per group. The outer SELECT then references + # agg.check_date — a fully grouped column — inside the correlated subqueries, + # satisfying only_full_group_by completely. cur.execute( f""" + WITH agg AS ( + SELECT + DATE(sc.checked_at) AS check_date, + sc.user_id, + COUNT(DISTINCT sc.website_id) AS checked_count + FROM shift_checks sc + {where_clause} + GROUP BY DATE(sc.checked_at), sc.user_id + ) SELECT - DATE(sc.checked_at) AS check_date, + agg.check_date, u.username, - COALESCE(u.full_name, u.username) AS full_name, - COUNT(DISTINCT sc.website_id) AS checked_count, + COALESCE(u.full_name, u.username) AS full_name, + agg.checked_count, ( SELECT COUNT(DISTINCT sw2.website_id) FROM shift_websites sw2 - JOIN shifts s2 ON s2.id = sw2.shift_id + JOIN shifts s2 ON s2.id = sw2.shift_id JOIN shift_users su2 ON su2.shift_id = s2.id - AND su2.user_id = u.id + AND su2.user_id = u.id WHERE s2.is_active = 1 AND LOCATE( - CAST(DAYOFWEEK(DATE(sc.checked_at)) AS CHAR), + CAST(DAYOFWEEK(agg.check_date) AS CHAR), s2.days_of_week ) > 0 ) AS total_sites, ROUND( - COUNT(DISTINCT sc.website_id) * 100.0 / + agg.checked_count * 100.0 / NULLIF(( SELECT COUNT(DISTINCT sw2.website_id) FROM shift_websites sw2 - JOIN shifts s2 ON s2.id = sw2.shift_id + JOIN shifts s2 ON s2.id = sw2.shift_id JOIN shift_users su2 ON su2.shift_id = s2.id - AND su2.user_id = u.id + AND su2.user_id = u.id WHERE s2.is_active = 1 AND LOCATE( - CAST(DAYOFWEEK(DATE(sc.checked_at)) AS CHAR), + CAST(DAYOFWEEK(agg.check_date) AS CHAR), s2.days_of_week ) > 0 ), 0), 1 ) AS pct_complete - FROM shift_checks sc - JOIN users u ON u.id = sc.user_id - {where_clause} - GROUP BY DATE(sc.checked_at), sc.user_id - ORDER BY check_date DESC, u.username + FROM agg + JOIN users u ON u.id = agg.user_id + ORDER BY agg.check_date DESC, u.username """, params )