Plain is headed towards 1.0! Subscribe for development updates →

plain.models

Model your data and store it in a database.

# app/users/models.py
from plain import models
from plain.passwords.models import PasswordField


class User(models.Model):
    email = models.EmailField()
    password = PasswordField()
    is_admin = models.BooleanField(default=False)
    created_at = models.DateTimeField(auto_now_add=True)

    def __str__(self):
        return self.email

Create, update, and delete instances of your models:

from .models import User


# Create a new user
user = User.objects.create(
    email="[email protected]",
    password="password",
)

# Update a user
user.email = "[email protected]"
user.save()

# Delete a user
user.delete()

# Query for users
admin_users = User.objects.filter(is_admin=True)

Installation

# app/settings.py
INSTALLED_PACKAGES = [
    ...
    "plain.models",
]

To connect to a database, you can provide a DATABASE_URL environment variable.

DATABASE_URL=postgresql://user:password@localhost:5432/dbname

Or you can manually define the DATABASES setting.

# app/settings.py
DATABASES = {
    "default": {
        "ENGINE": "plain.models.backends.postgresql",
        "NAME": "dbname",
        "USER": "user",
        "PASSWORD": "password",
        "HOST": "localhost",
        "PORT": "5432",
    }
}

Multiple backends are supported, including Postgres, MySQL, and SQLite.

Querying

Migrations

Migration docs

Fields

Field docs

Validation

Indexes and constraints

Managers

Forms

  1import inspect
  2from collections import defaultdict
  3from itertools import chain
  4
  5from plain.models.registry import models_registry
  6from plain.packages import packages_registry
  7from plain.preflight import Error, Warning, register_check
  8from plain.runtime import settings
  9
 10
 11@register_check
 12def check_database_backends(databases=None, **kwargs):
 13    if databases is None:
 14        return []
 15
 16    from plain.models.db import connections
 17
 18    issues = []
 19    for alias in databases:
 20        conn = connections[alias]
 21        issues.extend(conn.validation.check(**kwargs))
 22    return issues
 23
 24
 25@register_check
 26def check_all_models(package_configs=None, **kwargs):
 27    db_table_models = defaultdict(list)
 28    indexes = defaultdict(list)
 29    constraints = defaultdict(list)
 30    errors = []
 31    if package_configs is None:
 32        models = models_registry.get_models()
 33    else:
 34        models = chain.from_iterable(
 35            models_registry.get_models(package_label=package_config.label)
 36            for package_config in package_configs
 37        )
 38    for model in models:
 39        db_table_models[model._meta.db_table].append(model._meta.label)
 40        if not inspect.ismethod(model.check):
 41            errors.append(
 42                Error(
 43                    f"The '{model.__name__}.check()' class method is currently overridden by {model.check!r}.",
 44                    obj=model,
 45                    id="models.E020",
 46                )
 47            )
 48        else:
 49            errors.extend(model.check(**kwargs))
 50        for model_index in model._meta.indexes:
 51            indexes[model_index.name].append(model._meta.label)
 52        for model_constraint in model._meta.constraints:
 53            constraints[model_constraint.name].append(model._meta.label)
 54    if settings.DATABASE_ROUTERS:
 55        error_class, error_id = Warning, "models.W035"
 56        error_hint = (
 57            "You have configured settings.DATABASE_ROUTERS. Verify that %s "
 58            "are correctly routed to separate databases."
 59        )
 60    else:
 61        error_class, error_id = Error, "models.E028"
 62        error_hint = None
 63    for db_table, model_labels in db_table_models.items():
 64        if len(model_labels) != 1:
 65            model_labels_str = ", ".join(model_labels)
 66            errors.append(
 67                error_class(
 68                    f"db_table '{db_table}' is used by multiple models: {model_labels_str}.",
 69                    obj=db_table,
 70                    hint=(error_hint % model_labels_str) if error_hint else None,
 71                    id=error_id,
 72                )
 73            )
 74    for index_name, model_labels in indexes.items():
 75        if len(model_labels) > 1:
 76            model_labels = set(model_labels)
 77            errors.append(
 78                Error(
 79                    "index name '{}' is not unique {} {}.".format(
 80                        index_name,
 81                        "for model" if len(model_labels) == 1 else "among models:",
 82                        ", ".join(sorted(model_labels)),
 83                    ),
 84                    id="models.E029" if len(model_labels) == 1 else "models.E030",
 85                ),
 86            )
 87    for constraint_name, model_labels in constraints.items():
 88        if len(model_labels) > 1:
 89            model_labels = set(model_labels)
 90            errors.append(
 91                Error(
 92                    "constraint name '{}' is not unique {} {}.".format(
 93                        constraint_name,
 94                        "for model" if len(model_labels) == 1 else "among models:",
 95                        ", ".join(sorted(model_labels)),
 96                    ),
 97                    id="models.E031" if len(model_labels) == 1 else "models.E032",
 98                ),
 99            )
100    return errors
101
102
103def _check_lazy_references(models_registry, packages_registry):
104    """
105    Ensure all lazy (i.e. string) model references have been resolved.
106
107    Lazy references are used in various places throughout Plain, primarily in
108    related fields and model signals. Identify those common cases and provide
109    more helpful error messages for them.
110    """
111    pending_models = set(models_registry._pending_operations)
112
113    # Short circuit if there aren't any errors.
114    if not pending_models:
115        return []
116
117    def extract_operation(obj):
118        """
119        Take a callable found in Packages._pending_operations and identify the
120        original callable passed to Packages.lazy_model_operation(). If that
121        callable was a partial, return the inner, non-partial function and
122        any arguments and keyword arguments that were supplied with it.
123
124        obj is a callback defined locally in Packages.lazy_model_operation() and
125        annotated there with a `func` attribute so as to imitate a partial.
126        """
127        operation, args, keywords = obj, [], {}
128        while hasattr(operation, "func"):
129            args.extend(getattr(operation, "args", []))
130            keywords.update(getattr(operation, "keywords", {}))
131            operation = operation.func
132        return operation, args, keywords
133
134    def app_model_error(model_key):
135        try:
136            packages_registry.get_package_config(model_key[0])
137            model_error = "app '{}' doesn't provide model '{}'".format(*model_key)
138        except LookupError:
139            model_error = f"app '{model_key[0]}' isn't installed"
140        return model_error
141
142    # Here are several functions which return CheckMessage instances for the
143    # most common usages of lazy operations throughout Plain. These functions
144    # take the model that was being waited on as an (package_label, modelname)
145    # pair, the original lazy function, and its positional and keyword args as
146    # determined by extract_operation().
147
148    def field_error(model_key, func, args, keywords):
149        error_msg = (
150            "The field %(field)s was declared with a lazy reference "
151            "to '%(model)s', but %(model_error)s."
152        )
153        params = {
154            "model": ".".join(model_key),
155            "field": keywords["field"],
156            "model_error": app_model_error(model_key),
157        }
158        return Error(error_msg % params, obj=keywords["field"], id="fields.E307")
159
160    def default_error(model_key, func, args, keywords):
161        error_msg = (
162            "%(op)s contains a lazy reference to %(model)s, but %(model_error)s."
163        )
164        params = {
165            "op": func,
166            "model": ".".join(model_key),
167            "model_error": app_model_error(model_key),
168        }
169        return Error(error_msg % params, obj=func, id="models.E022")
170
171    # Maps common uses of lazy operations to corresponding error functions
172    # defined above. If a key maps to None, no error will be produced.
173    # default_error() will be used for usages that don't appear in this dict.
174    known_lazy = {
175        ("plain.models.fields.related", "resolve_related_class"): field_error,
176    }
177
178    def build_error(model_key, func, args, keywords):
179        key = (func.__module__, func.__name__)
180        error_fn = known_lazy.get(key, default_error)
181        return error_fn(model_key, func, args, keywords) if error_fn else None
182
183    return sorted(
184        filter(
185            None,
186            (
187                build_error(model_key, *extract_operation(func))
188                for model_key in pending_models
189                for func in models_registry._pending_operations[model_key]
190            ),
191        ),
192        key=lambda error: error.msg,
193    )
194
195
196@register_check
197def check_lazy_references(package_configs=None, **kwargs):
198    return _check_lazy_references(models_registry, packages_registry)
199
200
201@register_check
202def check_database_tables(package_configs, **kwargs):
203    from plain.models.db import connection
204
205    databases = kwargs.get("databases", None)
206    if not databases:
207        return []
208
209    errors = []
210
211    for database in databases:
212        db_tables = connection.introspection.table_names()
213        model_tables = connection.introspection.plain_table_names()
214
215        unknown_tables = set(db_tables) - set(model_tables)
216        unknown_tables.discard("plainmigrations")  # Know this could be there
217        if unknown_tables:
218            table_names = ", ".join(unknown_tables)
219            specific_hint = f'echo "DROP TABLE IF EXISTS {unknown_tables.pop()}" | plain models db-shell'
220            errors.append(
221                Warning(
222                    f"Unknown tables in {database} database: {table_names}",
223                    hint=(
224                        "Tables may be from packages/models that have been uninstalled. "
225                        "Make sure you have a backup and delete the tables manually "
226                        f"(ex. `{specific_hint}`)."
227                    ),
228                    id="plain.models.W001",
229                )
230            )
231
232    return errors