rustango 0.60.7

Batteries-included web framework for Rust: ORM + migrations + auto-admin + multi-tenancy + audit log + auth (sessions, JWT, OAuth2/OIDC, HMAC) + APIs (ViewSet, OpenAPI auto-derive, JSON:API) + jobs (in-mem + Postgres) + email + media (S3 / R2 / B2 / MinIO + presigned uploads + collections + tags) + production middleware (CSRF, CSP, rate-limiting, compression, idempotency, etc.).
Documentation
//! `date_hierarchy`: a clickable year/month/day strip above the admin
//! list view.
//!
//! When a model declares `#[rustango(admin(date_hierarchy = "<field>"))]`,
//! the list view:
//!
//! 1. Reads `?year=YYYY` `&month=MM` `&day=DD` from the query string.
//! 2. Adds two filters (`>= lo`, `< hi`) on the named column, for the
//!    half-open range `[lo, hi)` the selection implies.
//! 3. Counts the child buckets at the current level, within the list's
//!    filters: years when nothing is selected, then months, then days.
//! 4. Renders a breadcrumb and the child list above the table.
//!
//! The range comparison binds two `SqlValue::Date` or `DateTime`
//! params. No query value is ever put into the SQL text, so it cannot
//! become an injection vector.

use chrono::{NaiveDate, TimeZone, Utc};

use crate::core::{FieldType, Filter, ModelSchema, Op, SqlValue};

/// Selection extracted from the URL.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(crate) struct DateSelection {
    pub year: Option<i32>,
    pub month: Option<u32>,
    pub day: Option<u32>,
}

impl DateSelection {
    /// Parse year/month/day from the list view's query-param map.
    pub(crate) fn parse(params: &std::collections::HashMap<String, String>) -> Self {
        let year = params
            .get("year")
            .and_then(|s| s.parse::<i32>().ok())
            .filter(|y| (1..=9999).contains(y));
        let month = year.and(
            params
                .get("month")
                .and_then(|s| s.parse::<u32>().ok())
                .filter(|m| (1..=12).contains(m)),
        );
        let day = month.and(
            params
                .get("day")
                .and_then(|s| s.parse::<u32>().ok())
                .filter(|d| (1..=31).contains(d)),
        );
        Self { year, month, day }
    }
}

/// Half-open `[lo, hi)` range for the selection. `None` when no year is
/// set, which means there is nothing to narrow.
pub(crate) fn range(sel: DateSelection) -> Option<(NaiveDate, NaiveDate)> {
    let year = sel.year?;
    let lo = NaiveDate::from_ymd_opt(year, sel.month.unwrap_or(1), sel.day.unwrap_or(1))?;
    let hi = if sel.day.is_some() {
        lo.checked_add_signed(chrono::Duration::days(1))?
    } else if let Some(m) = sel.month {
        // Next month, rolling year on December → January.
        if m == 12 {
            NaiveDate::from_ymd_opt(year + 1, 1, 1)?
        } else {
            NaiveDate::from_ymd_opt(year, m + 1, 1)?
        }
    } else {
        NaiveDate::from_ymd_opt(year + 1, 1, 1)?
    };
    Some((lo, hi))
}

/// The two predicates that narrow the list view to the selected date
/// range. Empty when nothing is selected, or when the field is not a
/// `Date` or `DateTime`.
pub(crate) fn predicates(
    model: &'static ModelSchema,
    field_name: &str,
    sel: DateSelection,
) -> Vec<Filter> {
    let Some(field) = model.field(field_name) else {
        return Vec::new();
    };
    let Some((lo, hi)) = range(sel) else {
        return Vec::new();
    };
    match field.ty {
        FieldType::Date => {
            vec![
                Filter {
                    column: field.column,
                    op: Op::Gte,
                    value: SqlValue::Date(lo),
                },
                Filter {
                    column: field.column,
                    op: Op::Lt,
                    value: SqlValue::Date(hi),
                },
            ]
        }
        FieldType::DateTime => {
            // Bucket boundaries sit at UTC midnight.
            let lo_dt = Utc.from_utc_datetime(&lo.and_hms_opt(0, 0, 0).unwrap());
            let hi_dt = Utc.from_utc_datetime(&hi.and_hms_opt(0, 0, 0).unwrap());
            vec![
                Filter {
                    column: field.column,
                    op: Op::Gte,
                    value: SqlValue::DateTime(lo_dt),
                },
                Filter {
                    column: field.column,
                    op: Op::Lt,
                    value: SqlValue::DateTime(hi_dt),
                },
            ]
        }
        _ => Vec::new(),
    }
}

/// Drill level at which child buckets are enumerated.
#[derive(Debug, Clone, Copy)]
pub(crate) enum DrillLevel {
    Year,
    Month,
    Day,
}

impl DrillLevel {
    pub(crate) fn for_selection(sel: DateSelection) -> Option<Self> {
        match (sel.year, sel.month, sel.day) {
            (None, _, _) => Some(Self::Year),
            (Some(_), None, _) => Some(Self::Month),
            (Some(_), Some(_), None) => Some(Self::Day),
            (Some(_), Some(_), Some(_)) => None, // a day has no children
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn sel(y: Option<i32>, m: Option<u32>, d: Option<u32>) -> DateSelection {
        DateSelection {
            year: y,
            month: m,
            day: d,
        }
    }

    #[test]
    fn range_for_year_only_spans_calendar_year() {
        let (lo, hi) = range(sel(Some(2025), None, None)).unwrap();
        assert_eq!(lo, NaiveDate::from_ymd_opt(2025, 1, 1).unwrap());
        assert_eq!(hi, NaiveDate::from_ymd_opt(2026, 1, 1).unwrap());
    }

    #[test]
    fn range_for_year_and_month_rolls_at_december() {
        let (lo, hi) = range(sel(Some(2025), Some(12), None)).unwrap();
        assert_eq!(lo, NaiveDate::from_ymd_opt(2025, 12, 1).unwrap());
        assert_eq!(hi, NaiveDate::from_ymd_opt(2026, 1, 1).unwrap());
    }

    #[test]
    fn range_for_year_month_day_is_single_day() {
        let (lo, hi) = range(sel(Some(2025), Some(11), Some(15))).unwrap();
        assert_eq!(lo, NaiveDate::from_ymd_opt(2025, 11, 15).unwrap());
        assert_eq!(hi, NaiveDate::from_ymd_opt(2025, 11, 16).unwrap());
    }

    #[test]
    fn range_returns_none_with_no_year() {
        assert!(range(sel(None, None, None)).is_none());
    }

    #[test]
    fn drill_level_terminal_when_day_set() {
        assert!(DrillLevel::for_selection(sel(Some(2025), Some(11), Some(15))).is_none());
    }

    #[test]
    fn parse_drops_partial_selections() {
        // `?month=03` with no year is ignored: month and day need one.
        let mut p = std::collections::HashMap::new();
        p.insert("month".into(), "3".into());
        let s = DateSelection::parse(&p);
        assert_eq!(s, DateSelection::default());
    }
}