1use alembic_core::{FieldFormat, FieldType, Inventory, Schema, TypeName, TypeSchema};
4use anyhow::Result;
5use std::fs;
6use std::path::Path;
7
8const GENERATED_MODELS: &str = "generated_models.py";
9const GENERATED_ADMIN: &str = "generated_admin.py";
10const GENERATED_SERIALIZERS: &str = "generated_serializers.py";
11const GENERATED_VIEWS: &str = "generated_views.py";
12const GENERATED_URLS: &str = "generated_urls.py";
13const USER_MODELS: &str = "models.py";
14const USER_ADMIN: &str = "admin.py";
15const USER_SERIALIZERS: &str = "serializers.py";
16const USER_VIEWS: &str = "views.py";
17const USER_URLS: &str = "urls.py";
18const USER_EXTENSIONS: &str = "extensions.py";
19
20const MODELS_TEMPLATE: &str = include_str!("../templates/models.py.tpl");
21const ADMIN_TEMPLATE: &str = include_str!("../templates/admin.py.tpl");
22const SERIALIZERS_TEMPLATE: &str = include_str!("../templates/serializers.py.tpl");
23const VIEWS_TEMPLATE: &str = include_str!("../templates/views.py.tpl");
24const URLS_TEMPLATE: &str = include_str!("../templates/urls.py.tpl");
25const ADMIN_SEARCH_FIELDS: &[&str] = &["key", "uid"];
26
27#[derive(Debug)]
28struct ModelSpec {
29 class_name: String,
30 fields: Vec<FieldSpec>,
31 key_fields: Vec<String>,
32 has_validators: bool,
33}
34
35#[derive(Debug, Clone)]
36struct FieldSpec {
37 name: String,
38 field_type: DjangoFieldType,
39 required: bool,
40 nullable: bool,
41 choices: Option<Vec<String>>,
42 validators: Vec<String>,
43}
44
45#[derive(Debug, Clone)]
46enum DjangoFieldType {
47 Char,
48 Text,
49 Integer,
50 Float,
51 Boolean,
52 Uuid,
53 Date,
54 DateTime,
55 Time,
56 Json,
57 Slug,
58 IpAddress,
59 ForeignKey { target: String },
60 ManyToMany { target: String },
61}
62
63#[derive(Debug, Clone, Copy)]
64pub struct DjangoEmitOptions {
65 pub emit_admin: bool,
66}
67
68impl Default for DjangoEmitOptions {
69 fn default() -> Self {
70 Self { emit_admin: true }
71 }
72}
73
74pub fn emit_django_app(
75 app_dir: &Path,
76 inventory: &Inventory,
77 options: DjangoEmitOptions,
78) -> Result<()> {
79 fs::create_dir_all(app_dir)?;
80 let app_name = app_dir
81 .file_name()
82 .and_then(|name| name.to_str())
83 .unwrap_or("alembic_app");
84
85 let types = schema_types(&inventory.schema);
86 let models: Vec<ModelSpec> = types
87 .into_iter()
88 .map(|(name, schema)| model_spec_from_schema(&name, &schema))
89 .collect();
90
91 let rendered = render_files(&models, app_name, options.emit_admin);
92 fs::write(app_dir.join(GENERATED_MODELS), rendered.models)?;
93 if let Some(admin) = rendered.admin {
94 fs::write(app_dir.join(GENERATED_ADMIN), admin)?;
95 }
96 fs::write(app_dir.join(GENERATED_SERIALIZERS), rendered.serializers)?;
97 fs::write(app_dir.join(GENERATED_VIEWS), rendered.views)?;
98 fs::write(app_dir.join(GENERATED_URLS), rendered.urls)?;
99
100 write_user_file(
101 app_dir.join(USER_MODELS),
102 user_models_stub(),
103 &[default_models_stub()],
104 )?;
105 if options.emit_admin {
106 write_user_file(
107 app_dir.join(USER_ADMIN),
108 user_admin_stub(),
109 &[default_admin_stub()],
110 )?;
111 }
112 write_user_file(app_dir.join(USER_SERIALIZERS), user_serializers_stub(), &[])?;
113 write_user_file(
114 app_dir.join(USER_VIEWS),
115 user_views_stub(),
116 &[default_views_stub()],
117 )?;
118 write_user_file(app_dir.join(USER_URLS), user_urls_stub(), &[])?;
119 write_if_missing(app_dir.join(USER_EXTENSIONS), user_extensions_stub())?;
120
121 Ok(())
122}
123
124fn schema_types(schema: &Schema) -> Vec<(TypeName, TypeSchema)> {
125 let mut types: Vec<(String, TypeSchema)> = schema
126 .types
127 .iter()
128 .map(|(name, schema)| (name.clone(), schema.clone()))
129 .collect();
130 types.sort_by(|a, b| a.0.cmp(&b.0));
131 types
132 .into_iter()
133 .map(|(name, schema)| (TypeName::new(name), schema))
134 .collect()
135}
136
137fn model_spec_from_schema(type_name: &TypeName, schema: &TypeSchema) -> ModelSpec {
138 let class_name = class_name_for_type(type_name.as_str());
139 let mut fields = Vec::new();
140 let mut key_fields = Vec::new();
141 let mut has_validators = false;
142
143 for (field, field_schema) in schema.key.iter() {
144 let spec = field_spec_from_schema(field, field_schema, true);
145 if !spec.validators.is_empty() {
146 has_validators = true;
147 }
148 key_fields.push(field.to_string());
149 fields.push(spec);
150 }
151
152 for (field, field_schema) in schema.fields.iter() {
153 if schema.key.contains_key(field) {
154 continue;
155 }
156 let spec = field_spec_from_schema(field, field_schema, false);
157 if !spec.validators.is_empty() {
158 has_validators = true;
159 }
160 fields.push(spec);
161 }
162
163 ModelSpec {
164 class_name,
165 fields,
166 key_fields,
167 has_validators,
168 }
169}
170
171struct DjangoFiles {
172 models: String,
173 admin: Option<String>,
174 serializers: String,
175 views: String,
176 urls: String,
177}
178
179fn render_files(models: &[ModelSpec], app_name: &str, emit_admin: bool) -> DjangoFiles {
180 let model_names: Vec<String> = models.iter().map(|m| m.class_name.clone()).collect();
181 let model_import = import_line("from .generated_models import ", &model_names);
182 let serializer_names: Vec<String> = model_names
183 .iter()
184 .map(|name| format!("{name}Serializer"))
185 .collect();
186 let serializer_import = import_line("from .generated_serializers import ", &serializer_names);
187 let view_names: Vec<String> = model_names
188 .iter()
189 .map(|name| format!("{name}ViewSet"))
190 .collect();
191 let view_import = import_line("from .generated_views import ", &view_names);
192
193 let models_block = render_models_block(models);
194 let validators_import = if models.iter().any(|m| m.has_validators) {
195 "from django.core.validators import RegexValidator"
196 } else {
197 ""
198 };
199 let admins_block = if emit_admin {
200 render_admins_block(models)
201 } else {
202 String::new()
203 };
204 let serializers_block = render_serializers_block(models);
205 let views_block = render_views_block(models);
206 let routes_block = render_routes_block(models);
207
208 let models = render_template(
209 MODELS_TEMPLATE,
210 &[
211 ("validators_import", validators_import.to_string()),
212 ("models", models_block),
213 ],
214 );
215 let admin = if emit_admin {
216 Some(render_template(
217 ADMIN_TEMPLATE,
218 &[
219 ("model_import", model_import.clone()),
220 ("admins", admins_block),
221 ],
222 ))
223 } else {
224 None
225 };
226 let serializers = render_template(
227 SERIALIZERS_TEMPLATE,
228 &[
229 ("model_import", model_import.clone()),
230 ("serializers", serializers_block),
231 ],
232 );
233 let views = render_template(
234 VIEWS_TEMPLATE,
235 &[
236 ("model_import", model_import),
237 ("serializer_import", serializer_import),
238 ("views", views_block),
239 ],
240 );
241 let urls = render_template(
242 URLS_TEMPLATE,
243 &[
244 ("view_import", view_import),
245 ("routes", routes_block),
246 ("app_name", app_name.to_string()),
247 ],
248 );
249
250 DjangoFiles {
251 models,
252 admin,
253 serializers,
254 views,
255 urls,
256 }
257}
258
259fn render_field(field: &FieldSpec) -> String {
260 let mut args = Vec::new();
261 if let Some(choices) = &field.choices {
262 let choice_items = choices
263 .iter()
264 .map(|value| format!("(\"{value}\", \"{value}\")"))
265 .collect::<Vec<_>>()
266 .join(", ");
267 args.push(format!("choices=[{choice_items}]"));
268 }
269 if !field.validators.is_empty() {
270 let validators = field.validators.join(", ");
271 args.push(format!("validators=[{validators}]"));
272 }
273 if !field.required {
274 args.push("blank=True".to_string());
275 }
276 if field.nullable {
277 args.push("null=True".to_string());
278 }
279 if matches!(field.field_type, DjangoFieldType::IpAddress)
280 && args.iter().any(|arg| arg == "blank=True")
281 && !args.iter().any(|arg| arg == "null=True")
282 {
283 args.push("null=True".to_string());
284 }
285
286 let args_str = args.join(", ");
287
288 match &field.field_type {
289 DjangoFieldType::Char => {
290 if args_str.is_empty() {
291 format!("{} = models.CharField(max_length=255)", field.name)
292 } else {
293 format!(
294 "{} = models.CharField(max_length=255, {})",
295 field.name, args_str
296 )
297 }
298 }
299 DjangoFieldType::Text => format!("{} = models.TextField({})", field.name, args_str),
300 DjangoFieldType::Integer => format!("{} = models.IntegerField({})", field.name, args_str),
301 DjangoFieldType::Float => format!("{} = models.FloatField({})", field.name, args_str),
302 DjangoFieldType::Boolean => format!("{} = models.BooleanField({})", field.name, args_str),
303 DjangoFieldType::Uuid => format!("{} = models.UUIDField({})", field.name, args_str),
304 DjangoFieldType::Date => format!("{} = models.DateField({})", field.name, args_str),
305 DjangoFieldType::DateTime => format!("{} = models.DateTimeField({})", field.name, args_str),
306 DjangoFieldType::Time => format!("{} = models.TimeField({})", field.name, args_str),
307 DjangoFieldType::Json => format!("{} = models.JSONField({})", field.name, args_str),
308 DjangoFieldType::Slug => format!("{} = models.SlugField({})", field.name, args_str),
309 DjangoFieldType::IpAddress => {
310 format!(
311 "{} = models.GenericIPAddressField({})",
312 field.name, args_str
313 )
314 }
315 DjangoFieldType::ForeignKey { target } => {
316 let mut fk_args = vec![
317 format!("\"{}\"", target),
318 "on_delete=models.PROTECT".to_string(),
319 ];
320 fk_args.extend(args);
321 format!("{} = models.ForeignKey({})", field.name, fk_args.join(", "))
322 }
323 DjangoFieldType::ManyToMany { target } => {
324 let mut m2m_args = vec![format!("\"{}\"", target)];
325 m2m_args.extend(args.into_iter().filter(|arg| arg != "null=True"));
326 format!(
327 "{} = models.ManyToManyField({})",
328 field.name,
329 m2m_args.join(", ")
330 )
331 }
332 }
333}
334
335fn render_models_block(models: &[ModelSpec]) -> String {
336 models
337 .iter()
338 .map(render_model_block)
339 .collect::<Vec<String>>()
340 .join("\n")
341}
342
343fn render_model_block(model: &ModelSpec) -> String {
344 let mut fields = Vec::with_capacity(model.fields.len() + 3);
345 fields.push("uid = models.UUIDField(primary_key=True, editable=False)".to_string());
346 fields.push("key = models.TextField()".to_string());
347 fields.push("attrs = models.JSONField(default=dict, blank=True)".to_string());
348 for field in &model.fields {
349 fields.push(render_field(field));
350 }
351
352 let mut lines = Vec::new();
353 lines.push(format!("class {}(models.Model):", model.class_name));
354 lines.push(format!(" {}", fields.join("\n ")));
355
356 if !model.key_fields.is_empty() {
357 let unique_fields = model
358 .key_fields
359 .iter()
360 .map(|field| format!("\"{field}\""))
361 .collect::<Vec<_>>()
362 .join(", ");
363 lines.push("".to_string());
364 lines.push(" class Meta:".to_string());
365 lines.push(format!(
366 " constraints = [models.UniqueConstraint(fields=[{unique_fields}], name=\"{}_key\")]",
367 model.class_name.to_lowercase()
368 ));
369 }
370
371 lines.join("\n") + "\n"
372}
373
374fn render_admins_block(models: &[ModelSpec]) -> String {
375 models
376 .iter()
377 .map(render_admin_block)
378 .collect::<Vec<String>>()
379 .join("\n")
380}
381
382fn render_admin_block(model: &ModelSpec) -> String {
383 let list_display = admin_list_display(model);
384 let list_filter = admin_list_filter(model);
385 let mut lines = vec![
386 format!(
387 "@admin.register({})\nclass {}Admin(admin.ModelAdmin):",
388 model.class_name, model.class_name
389 ),
390 format!(" list_display = [{}]", join_quoted(&list_display)),
391 format!(" search_fields = [{}]", join_quoted(ADMIN_SEARCH_FIELDS)),
392 ];
393 if !list_filter.is_empty() {
394 lines.push(format!(" list_filter = [{}]", join_quoted(&list_filter)));
395 }
396 lines.join("\n")
397}
398
399fn render_serializers_block(models: &[ModelSpec]) -> String {
400 models
401 .iter()
402 .map(render_serializer_block)
403 .collect::<Vec<String>>()
404 .join("\n")
405}
406
407fn render_serializer_block(model: &ModelSpec) -> String {
408 let fields = serializer_fields(model);
409 format!(
410 "class {}Serializer(serializers.ModelSerializer):\n class Meta:\n model = {}\n fields = [{}]\n",
411 model.class_name,
412 model.class_name,
413 join_quoted(&fields)
414 )
415}
416
417fn render_views_block(models: &[ModelSpec]) -> String {
418 models
419 .iter()
420 .map(render_view_block)
421 .collect::<Vec<String>>()
422 .join("\n")
423}
424
425fn render_view_block(model: &ModelSpec) -> String {
426 let list_display = admin_list_display(model);
427 let search_fields = admin_search_fields_for_model(model);
428 format!(
429 "class {}ViewSet(viewsets.ModelViewSet):\n queryset = {}.objects.all()\n serializer_class = {}Serializer\n filterset_fields = [{}]\n search_fields = [{}]\n ordering_fields = [{}]\n",
430 model.class_name,
431 model.class_name,
432 model.class_name,
433 join_quoted(&list_display),
434 join_quoted(&search_fields),
435 join_quoted(&list_display)
436 )
437}
438
439fn render_routes_block(models: &[ModelSpec]) -> String {
440 models
441 .iter()
442 .map(|model| {
443 let endpoint = pluralize(model.class_name.to_lowercase().as_str());
444 format!(
445 "router.register(\"{endpoint}\", {}ViewSet)",
446 model.class_name
447 )
448 })
449 .collect::<Vec<String>>()
450 .join("\n")
451}
452
453fn field_name(field: &FieldSpec) -> &str {
454 field.name.as_str()
455}
456
457fn join_quoted(fields: &[&str]) -> String {
458 fields
459 .iter()
460 .map(|field| format!("\"{field}\""))
461 .collect::<Vec<String>>()
462 .join(", ")
463}
464
465fn import_line<T: AsRef<str>>(prefix: &str, names: &[T]) -> String {
466 if names.is_empty() {
467 String::new()
468 } else {
469 format!(
470 "{prefix}{}",
471 names
472 .iter()
473 .map(|name| name.as_ref())
474 .collect::<Vec<&str>>()
475 .join(", ")
476 )
477 }
478}
479
480fn admin_list_display(model: &ModelSpec) -> Vec<&str> {
481 let mut fields = vec!["key", "uid"];
482 for field in &model.fields {
483 fields.push(field_name(field));
484 }
485 fields
486}
487
488fn admin_search_fields_for_model(model: &ModelSpec) -> Vec<&str> {
489 let mut fields = vec!["key"];
490 for field in &model.fields {
491 match field.field_type {
492 DjangoFieldType::Char
493 | DjangoFieldType::Text
494 | DjangoFieldType::Slug
495 | DjangoFieldType::Uuid
496 | DjangoFieldType::IpAddress => fields.push(field_name(field)),
497 _ => {}
498 }
499 }
500 fields
501}
502
503fn admin_list_filter(model: &ModelSpec) -> Vec<&str> {
504 let mut fields = Vec::new();
505 for field in &model.fields {
506 match field.field_type {
507 DjangoFieldType::Boolean => fields.push(field_name(field)),
508 _ => {
509 if field.name == "status" {
510 fields.push(field_name(field));
511 }
512 }
513 }
514 }
515 fields
516}
517
518fn serializer_fields(model: &ModelSpec) -> Vec<&str> {
519 let mut fields = vec!["uid", "key", "attrs"];
520 for field in &model.fields {
521 fields.push(field_name(field));
522 }
523 fields
524}
525
526fn class_name_for_type(type_name: &str) -> String {
527 type_name
528 .split('.')
529 .map(|segment| {
530 segment
531 .split('_')
532 .filter(|s| !s.is_empty())
533 .map(|part| {
534 let mut chars = part.chars();
535 match chars.next() {
536 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
537 None => String::new(),
538 }
539 })
540 .collect::<String>()
541 })
542 .collect::<String>()
543}
544
545fn pluralize(name: &str) -> String {
546 if name.ends_with('s') {
547 format!("{name}es")
548 } else if let Some(stripped) = name.strip_suffix('y') {
549 format!("{}ies", stripped)
550 } else {
551 format!("{name}s")
552 }
553}
554
555fn field_spec_from_schema(
556 name: &str,
557 schema: &alembic_core::FieldSchema,
558 is_key: bool,
559) -> FieldSpec {
560 let mut validators = Vec::new();
561 let mut choices = None;
562
563 if let Some(format) = &schema.format {
564 validators.push(format_validator(format));
565 }
566 if let Some(pattern) = &schema.pattern {
567 validators.push(format!("RegexValidator(r\"{pattern}\")"));
568 }
569
570 let field_type = match &schema.r#type {
571 FieldType::String => DjangoFieldType::Char,
572 FieldType::Text => DjangoFieldType::Text,
573 FieldType::Int => DjangoFieldType::Integer,
574 FieldType::Float => DjangoFieldType::Float,
575 FieldType::Bool => DjangoFieldType::Boolean,
576 FieldType::Uuid => DjangoFieldType::Uuid,
577 FieldType::Date => DjangoFieldType::Date,
578 FieldType::Datetime => DjangoFieldType::DateTime,
579 FieldType::Time => DjangoFieldType::Time,
580 FieldType::Json => DjangoFieldType::Json,
581 FieldType::IpAddress => DjangoFieldType::IpAddress,
582 FieldType::Cidr | FieldType::Prefix | FieldType::Mac => {
583 validators.push(format_validator(&format_for_field_type(&schema.r#type)));
584 DjangoFieldType::Char
585 }
586 FieldType::Slug => DjangoFieldType::Slug,
587 FieldType::Enum { values } => {
588 choices = Some(values.clone());
589 DjangoFieldType::Char
590 }
591 FieldType::List { .. } | FieldType::Map { .. } => DjangoFieldType::Json,
592 FieldType::Ref { target } => DjangoFieldType::ForeignKey {
593 target: class_name_for_type(target),
594 },
595 FieldType::ListRef { target } => DjangoFieldType::ManyToMany {
596 target: class_name_for_type(target),
597 },
598 };
599
600 let required = schema.required || is_key;
601 let nullable = schema.nullable && !is_key;
602
603 FieldSpec {
604 name: name.to_string(),
605 field_type,
606 required,
607 nullable,
608 choices,
609 validators,
610 }
611}
612
613fn format_for_field_type(field_type: &FieldType) -> FieldFormat {
614 match field_type {
615 FieldType::IpAddress => FieldFormat::IpAddress,
616 FieldType::Cidr => FieldFormat::Cidr,
617 FieldType::Prefix => FieldFormat::Prefix,
618 FieldType::Mac => FieldFormat::Mac,
619 FieldType::Uuid => FieldFormat::Uuid,
620 FieldType::Slug => FieldFormat::Slug,
621 _ => FieldFormat::Slug,
622 }
623}
624
625fn format_validator(format: &FieldFormat) -> String {
626 match format {
627 FieldFormat::Slug => "RegexValidator(r\"^[a-z0-9]+(?:[a-z0-9_-]*[a-z0-9])?$\")".to_string(),
628 FieldFormat::IpAddress => {
629 "RegexValidator(r\"^([0-9]{1,3}\\.){3}[0-9]{1,3}$|^[0-9a-fA-F:]+$\")".to_string()
630 }
631 FieldFormat::Cidr | FieldFormat::Prefix => {
632 "RegexValidator(r\"^[0-9a-fA-F:\\./]+$\")".to_string()
633 }
634 FieldFormat::Mac => {
635 "RegexValidator(r\"^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$\")".to_string()
636 }
637 FieldFormat::Uuid => {
638 "RegexValidator(r\"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$\")".to_string()
639 }
640 }
641}
642
643fn write_if_missing(path: impl AsRef<Path>, contents: &str) -> Result<()> {
644 let path = path.as_ref();
645 if path.exists() {
646 return Ok(());
647 }
648 fs::write(path, contents)?;
649 Ok(())
650}
651
652fn render_template(template: &str, vars: &[(&str, String)]) -> String {
653 let mut output = template.to_string();
654 for (key, value) in vars {
655 let token = format!("{{{{{key}}}}}");
656 output = output.replace(&token, value);
657 }
658 output
659}
660
661fn write_user_file(path: impl AsRef<Path>, contents: &str, defaults: &[&str]) -> Result<()> {
662 let path = path.as_ref();
663 if path.exists() {
664 let existing = fs::read_to_string(path)?;
665 let normalized = existing.trim().replace("\r\n", "\n");
666 let is_default = defaults
667 .iter()
668 .any(|candidate| candidate.trim().replace("\r\n", "\n") == normalized);
669 if !is_default {
670 return Ok(());
671 }
672 }
673 fs::write(path, contents)?;
674 Ok(())
675}
676
677fn user_models_stub() -> &'static str {
678 "from .generated_models import * # noqa: F401,F403\nfrom .extensions import * # noqa: F401,F403\n"
679}
680
681fn user_admin_stub() -> &'static str {
682 "from .generated_admin import * # noqa: F401,F403\nfrom .extensions import * # noqa: F401,F403\n"
683}
684
685fn user_serializers_stub() -> &'static str {
686 "from .generated_serializers import * # noqa: F401,F403\nfrom .extensions import * # noqa: F401,F403\n"
687}
688
689fn user_views_stub() -> &'static str {
690 "from .generated_views import * # noqa: F401,F403\nfrom .extensions import * # noqa: F401,F403\n"
691}
692
693fn user_urls_stub() -> &'static str {
694 "from .generated_urls import * # noqa: F401,F403\n"
695}
696
697fn user_extensions_stub() -> &'static str {
698 "# User extension hooks live here.\n"
699}
700
701fn default_models_stub() -> &'static str {
702 "from django.db import models\n\n# Create your models here.\n"
703}
704
705fn default_admin_stub() -> &'static str {
706 "from django.contrib import admin\n\n# Register your models here.\n"
707}
708
709fn default_views_stub() -> &'static str {
710 "from django.shortcuts import render\n\n# Create your views here.\n"
711}
712
713#[cfg(test)]
714mod tests {
715 use super::*;
716 use alembic_core::{
717 FieldSchema, FieldType, Inventory, JsonMap, Object, Schema, TypeName, TypeSchema,
718 };
719 use serde_json::{json, Value};
720 use std::collections::BTreeMap;
721 use tempfile::tempdir;
722 use uuid::Uuid;
723
724 fn attrs_map(pairs: Vec<(&str, Value)>) -> JsonMap {
725 JsonMap::from(
726 pairs
727 .into_iter()
728 .map(|(key, value)| (key.to_string(), value))
729 .collect::<BTreeMap<_, _>>(),
730 )
731 }
732
733 fn key_str(raw: &str) -> alembic_core::Key {
734 let mut map = BTreeMap::new();
735 for segment in raw.split('/') {
736 let (field, value) = segment
737 .split_once('=')
738 .unwrap_or_else(|| panic!("invalid key segment: {segment}"));
739 map.insert(field.to_string(), Value::String(value.to_string()));
740 }
741 alembic_core::Key::from(map)
742 }
743
744 fn obj(uid: u128, type_name: &str, key: &str, attrs: JsonMap) -> Object {
745 Object::new(
746 Uuid::from_u128(uid),
747 TypeName::new(type_name),
748 key_str(key),
749 attrs,
750 )
751 .unwrap()
752 }
753
754 fn test_schema() -> Schema {
755 let mut types = BTreeMap::new();
756 types.insert(
757 "dcim.site".to_string(),
758 TypeSchema {
759 key: BTreeMap::from([(
760 "slug".to_string(),
761 FieldSchema {
762 r#type: FieldType::Slug,
763 required: true,
764 nullable: false,
765 description: None,
766 format: None,
767 pattern: None,
768 },
769 )]),
770 fields: BTreeMap::from([
771 (
772 "name".to_string(),
773 FieldSchema {
774 r#type: FieldType::String,
775 required: true,
776 nullable: false,
777 description: None,
778 format: None,
779 pattern: None,
780 },
781 ),
782 (
783 "slug".to_string(),
784 FieldSchema {
785 r#type: FieldType::Slug,
786 required: true,
787 nullable: false,
788 description: None,
789 format: None,
790 pattern: None,
791 },
792 ),
793 ]),
794 },
795 );
796 types.insert(
797 "dcim.device".to_string(),
798 TypeSchema {
799 key: BTreeMap::from([(
800 "name".to_string(),
801 FieldSchema {
802 r#type: FieldType::Slug,
803 required: true,
804 nullable: false,
805 description: None,
806 format: None,
807 pattern: None,
808 },
809 )]),
810 fields: BTreeMap::from([
811 (
812 "name".to_string(),
813 FieldSchema {
814 r#type: FieldType::String,
815 required: true,
816 nullable: false,
817 description: None,
818 format: None,
819 pattern: None,
820 },
821 ),
822 (
823 "site".to_string(),
824 FieldSchema {
825 r#type: FieldType::Ref {
826 target: "dcim.site".to_string(),
827 },
828 required: true,
829 nullable: false,
830 description: None,
831 format: None,
832 pattern: None,
833 },
834 ),
835 (
836 "role".to_string(),
837 FieldSchema {
838 r#type: FieldType::String,
839 required: true,
840 nullable: false,
841 description: None,
842 format: None,
843 pattern: None,
844 },
845 ),
846 (
847 "device_type".to_string(),
848 FieldSchema {
849 r#type: FieldType::String,
850 required: true,
851 nullable: false,
852 description: None,
853 format: None,
854 pattern: None,
855 },
856 ),
857 ]),
858 },
859 );
860 types.insert(
861 "dcim.interface".to_string(),
862 TypeSchema {
863 key: BTreeMap::from([(
864 "name".to_string(),
865 FieldSchema {
866 r#type: FieldType::Slug,
867 required: true,
868 nullable: false,
869 description: None,
870 format: None,
871 pattern: None,
872 },
873 )]),
874 fields: BTreeMap::from([
875 (
876 "name".to_string(),
877 FieldSchema {
878 r#type: FieldType::String,
879 required: true,
880 nullable: false,
881 description: None,
882 format: None,
883 pattern: None,
884 },
885 ),
886 (
887 "device".to_string(),
888 FieldSchema {
889 r#type: FieldType::Ref {
890 target: "dcim.device".to_string(),
891 },
892 required: true,
893 nullable: false,
894 description: None,
895 format: None,
896 pattern: None,
897 },
898 ),
899 ]),
900 },
901 );
902 Schema { types }
903 }
904
905 fn sample_inventory() -> Inventory {
906 let objects = vec![
907 obj(
908 1,
909 "dcim.device",
910 "name=leaf01",
911 attrs_map(vec![
912 ("name", json!("leaf01")),
913 ("site", json!(Uuid::from_u128(2).to_string())),
914 ("role", json!("leaf")),
915 ("device_type", json!("leaf-switch")),
916 ]),
917 ),
918 obj(
919 2,
920 "dcim.site",
921 "slug=fra1",
922 attrs_map(vec![("name", json!("FRA1")), ("slug", json!("fra1"))]),
923 ),
924 obj(
925 3,
926 "dcim.interface",
927 "name=eth0",
928 attrs_map(vec![
929 ("name", json!("eth0")),
930 ("device", json!(Uuid::from_u128(1).to_string())),
931 ]),
932 ),
933 ];
934 Inventory {
935 schema: test_schema(),
936 objects,
937 }
938 }
939
940 #[test]
941 fn emit_django_app_writes_files_and_stubs() {
942 let dir = tempdir().unwrap();
943 emit_django_app(
944 dir.path(),
945 &sample_inventory(),
946 DjangoEmitOptions::default(),
947 )
948 .unwrap();
949
950 assert!(dir.path().join(GENERATED_MODELS).exists());
951 assert!(dir.path().join(GENERATED_ADMIN).exists());
952 assert!(dir.path().join(GENERATED_SERIALIZERS).exists());
953 assert!(dir.path().join(GENERATED_VIEWS).exists());
954 assert!(dir.path().join(GENERATED_URLS).exists());
955 assert!(dir.path().join(USER_MODELS).exists());
956 assert!(dir.path().join(USER_ADMIN).exists());
957 assert!(dir.path().join(USER_SERIALIZERS).exists());
958 assert!(dir.path().join(USER_VIEWS).exists());
959 assert!(dir.path().join(USER_URLS).exists());
960 assert!(dir.path().join(USER_EXTENSIONS).exists());
961
962 let models = fs::read_to_string(dir.path().join(GENERATED_MODELS)).unwrap();
963 assert!(models.contains("class DcimSite"));
964 assert!(models.contains("site = models.ForeignKey(\"DcimSite\""));
965 assert!(models.contains("device = models.ForeignKey(\"DcimDevice\""));
966 assert!(models.contains("uid = models.UUIDField"));
967 assert!(models.contains("attrs = models.JSONField"));
968 }
969
970 #[test]
971 fn emit_django_app_does_not_overwrite_user_files() {
972 let dir = tempdir().unwrap();
973 let models_path = dir.path().join(USER_MODELS);
974 let admin_path = dir.path().join(USER_ADMIN);
975 fs::write(&models_path, "# user models\n").unwrap();
976 fs::write(&admin_path, "# user admin\n").unwrap();
977
978 emit_django_app(
979 dir.path(),
980 &sample_inventory(),
981 DjangoEmitOptions::default(),
982 )
983 .unwrap();
984
985 assert_eq!(fs::read_to_string(models_path).unwrap(), "# user models\n");
986 assert_eq!(fs::read_to_string(admin_path).unwrap(), "# user admin\n");
987 }
988
989 #[test]
990 fn emit_django_app_overwrites_default_skeleton() {
991 let dir = tempdir().unwrap();
992 let models_path = dir.path().join(USER_MODELS);
993 let admin_path = dir.path().join(USER_ADMIN);
994 let views_path = dir.path().join(USER_VIEWS);
995 fs::write(&models_path, default_models_stub()).unwrap();
996 fs::write(&admin_path, default_admin_stub()).unwrap();
997 fs::write(&views_path, default_views_stub()).unwrap();
998
999 emit_django_app(
1000 dir.path(),
1001 &sample_inventory(),
1002 DjangoEmitOptions::default(),
1003 )
1004 .unwrap();
1005
1006 let models = fs::read_to_string(models_path).unwrap();
1007 let admin = fs::read_to_string(admin_path).unwrap();
1008 let views = fs::read_to_string(views_path).unwrap();
1009 assert!(models.contains("generated_models"));
1010 assert!(admin.contains("generated_admin"));
1011 assert!(views.contains("generated_views"));
1012 }
1013
1014 #[test]
1015 fn generated_admin_includes_defaults() {
1016 let dir = tempdir().unwrap();
1017 emit_django_app(
1018 dir.path(),
1019 &sample_inventory(),
1020 DjangoEmitOptions::default(),
1021 )
1022 .unwrap();
1023 let admin = fs::read_to_string(dir.path().join(GENERATED_ADMIN)).unwrap();
1024
1025 assert!(admin.contains("class DcimDeviceAdmin"));
1026 assert!(admin.contains(
1027 "list_display = [\"key\", \"uid\", \"name\", \"device_type\", \"role\", \"site\"]"
1028 ));
1029 assert!(admin.contains("search_fields = [\"key\", \"uid\"]"));
1030 assert!(admin.contains("class DcimInterfaceAdmin"));
1031 }
1032
1033 #[test]
1034 fn generated_api_files_include_models() {
1035 let dir = tempdir().unwrap();
1036 emit_django_app(
1037 dir.path(),
1038 &sample_inventory(),
1039 DjangoEmitOptions::default(),
1040 )
1041 .unwrap();
1042 let serializers = fs::read_to_string(dir.path().join(GENERATED_SERIALIZERS)).unwrap();
1043 let views = fs::read_to_string(dir.path().join(GENERATED_VIEWS)).unwrap();
1044 let urls = fs::read_to_string(dir.path().join(GENERATED_URLS)).unwrap();
1045
1046 assert!(serializers.contains("class DcimDeviceSerializer"));
1047 assert!(views.contains("class DcimDeviceViewSet"));
1048 assert!(urls.contains("router.register(\"dcimdevices\""));
1049 assert!(urls.contains("schema_view"));
1050 }
1051
1052 #[test]
1053 fn generated_models_are_deterministic_by_kind() {
1054 let dir = tempdir().unwrap();
1055 emit_django_app(
1056 dir.path(),
1057 &sample_inventory(),
1058 DjangoEmitOptions::default(),
1059 )
1060 .unwrap();
1061
1062 let models = fs::read_to_string(dir.path().join(GENERATED_MODELS)).unwrap();
1063 let device_pos = models.find("class DcimDevice").unwrap();
1064 let interface_pos = models.find("class DcimInterface").unwrap();
1065 let site_pos = models.find("class DcimSite").unwrap();
1066 assert!(device_pos < interface_pos);
1067 assert!(interface_pos < site_pos);
1068 }
1069}