Repository: m1kc/django-spinproject (language: python)

Issue:
Upgrade dockerignore and gitlab-ci

poetry install --sync



Discussion / hints:

Fix patch:
diff --git a/.gitignore b/.gitignore
index f92c5f8..86a51aa 100644
--- a/.gitignore
+++ b/.gitignore
@@ -176,4 +176,7 @@ dmypy.json
 
 # Pyre type checker
 
+# PyCharm
+.idea/
+
 # End of https://www.gitignore.io/api/python,django,code
diff --git a/README.md b/README.md
index 1080445..66c3d79 100644
--- a/README.md
+++ b/README.md
@@ -22,14 +22,39 @@ Generated files will work fine in Django >= 2.0, not tested in earlier versions.
 
 ## How to use
 
-1. Install the package: `pip install django-spinproject`
-2. `django-spinproject <path>`
+* Install the package: `pip install django-spinproject`
+* Create a new project: `django-spinproject --create <path>`
+* OR initialize spinproject in your existing project's folder: `django-spinproject --init`
+
+You're all set. Now you can take a look at the list of available modules: `django-spinproject --help`
+
+Use `django-spinproject --enable` to enable a module, `django-spinproject --upgrade` to apply changes.
+
+## Other commands
+
+* `--create PATH`: create django project in specified path 
+* `--init`: create spinproject.json file
+* `--enable MODULE_TO_ENABLE [MODULE_TO_ENABLE ...]`: enable specified module(s). use 'all' to enable all modules
+* `--disable MODULE_TO_DISABLE`: disable specified module
+* `--upgrade [MODULE_TO_UPGRADE [MODULE_TO_UPGRADE ...]]`: upgrade (specified or all) enabled modules
+
+## Available modules
+
+* `gitignore` — Creates `.gitignore` file suitable for most Django projects.
+* `srta` — Creates [Scripts to Rule Them All](https://github.blog/2015-06-30-scripts-to-rule-them-all/) (simplifies life a lot, you should check it out).
+* `pytest` — Creates `pytest.ini` and `.coveragerc` files.
+* `dockerfile` — Creates a Dockerfile.
+* `dockerignore` — Creates `.dockerignore` (you should totally do that).
+* `docker-scripts` — Creates additional SRTA scripts for building and pushing your Docker image.
+* `gitlab-ci` — Creates GitLab CI config, `.gitlab-ci.yml`.
+* `pg-readonly` — Creates a DatabaseWrapper class for readonly connection to PostgreSQL.
+* `settings` — Improves the default `settings.py`, adding support for envvars and `.env` files. Also enables Whitenoise and CLI logger.
 
 ## Planned features
 
 (for requests, create an issue or drop me a line at m1kc@yandex.ru)
 
-* Some CLI flags to switch off the things you don't need.
+* login page template (see `experimental` branch)
 
 ## Changelog
 
diff --git a/django_spinproject/bin/spinproject.py b/django_spinproject/bin/spinproject.py
index ba1a338..accd414 100755
--- a/django_spinproject/bin/spinproject.py
+++ b/django_spinproject/bin/spinproject.py
@@ -1,47 +1,64 @@
 #!/usr/bin/env python3
 
+from ..project_manager.project_info_manager import ProjectInfoManager
+from ..generic.exit import exit_with_output
+from ..cli import create_argparser
+from ..modules import MODULES
+from ..constants import DEFAULT_MAIN
+
 import os
-import sys
 import subprocess
 
+
 def main():
-	argv = sys.argv[1:]
-	if (len(argv) not in [1,2]) or (argv == ['-h']) or (argv == ['--help']):
-		print('Usage:')
-		print('  startproject.py <path>')
-		print('')
-		print('Settings directory will be called `main`. You can override this')
-		print('by passing 2nd argument (deprecated).')
-		sys.exit(2)
-
-	name = 'main'
-	path = argv[0]
-	if len(argv) == 2:
-		name = argv[1]
-
-	print(f"Creating project at `{path}`")
-
-	subprocess.run(['mkdir', '-p', path], check=True)
-	subprocess.run(['django-admin', 'startproject', name, path], check=True)
-
-	BASE_DIR = os.path.dirname(os.path.abspath(__file__))
-
-
-	active_modules = [
-		'srta',
-		'dockerfile',
-		'makefile',
-		'settings',
-		# 'pytest',
-		'gitignore',
-		'dockerignore',
-		'pg-readonly',
-		'gitlab-ci',
-		'pytest',
-	]
-
-	for m in active_modules:
-		subprocess.run([os.path.join(BASE_DIR, f'enhance-{m}.py'), name, path], check=True)
+	argparser = create_argparser()
+	args = argparser.parse_args()
+
+	if args.directory is not None:
+		exit_with_output("""Please use the new syntax:
+  django-spinproject --create <path_to_project>
+
+Old-style syntax is only available in versions < 2.""")
+
+	elif args.project_creation_data is not None:
+		path = args.project_creation_data[0]
+		name = DEFAULT_MAIN
+
+		subprocess.run(['mkdir', '-p', path], check=True)
+		subprocess.run(['django-admin', 'startproject', name, path], check=True)
+		os.chdir(path)
+		ProjectInfoManager.init()
+		print(f'''Created new project at path: {path}
+
+Use django-spinproject --help to see which modules are available,
+turn them on with --enable.
+
+Edit spinproject.json to configure project settings.
+
+Happy hacking!''')
+
+	elif args.init_project_info:
+		ProjectInfoManager.init()
+		print(f'''Use django-spinproject --help to see which modules are available,
+turn them on with --enable.
+
+Edit spinproject.json to configure project settings.
+
+Happy hacking!''')
+
+	elif args.modules_to_enable is not None:
+		if args.modules_to_enable[0] == 'all':
+			modules_to_enable = list(MODULES.keys())
+		else:
+			modules_to_enable = args.modules_to_enable
+
+		ProjectInfoManager.enable_modules(*modules_to_enable)
+
+	elif args.module_to_disable is not None:
+		ProjectInfoManager.disable_module(args.module_to_disable[0])
+
+	elif args.modules_to_upgrade is not None:
+		ProjectInfoManager.upgrade_modules(*args.modules_to_upgrade)
 
 
 if __name__ == '__main__':
diff --git a/django_spinproject/cli.py b/django_spinproject/cli.py
new file mode 100644
index 0000000..8eca2fd
--- /dev/null
+++ b/django_spinproject/cli.py
@@ -0,0 +1,75 @@
+from .modules import MODULES
+
+import argparse
+import textwrap
+
+
+DESCRIPTION = """Opinionated version of django-admin startproject
+that intends to go further and do things that startproject can't do
+but most people will do anyway.
+"""
+MODULES_HELP = '  ' + '\n  '.join(map(lambda x: x + '\n    ' + MODULES[x].help_text.replace('\n', '\n    '), MODULES))
+EPILOG = f"""Allowed modules:
+{MODULES_HELP}
+"""
+
+
+def create_argparser() -> argparse.ArgumentParser:
+	parser = argparse.ArgumentParser(
+		prog='django-spinproject',
+		description=DESCRIPTION,
+		epilog=textwrap.dedent(EPILOG),
+		formatter_class=argparse.RawDescriptionHelpFormatter,
+	)
+	parser.add_argument(
+		'directory',
+		type=str,
+		nargs='?',
+		help="project destination directory (deprecated).",
+	)
+	parser.add_argument(
+		'--create',
+		dest='project_creation_data',
+		metavar='PATH',
+		help=f"create project in specified path.",
+		action='store',
+		type=str,
+		nargs=1,
+	)
+	parser.add_argument(
+		'--init',
+		dest='init_project_info',
+		help="initialize project info file.",
+		action='store_true',
+	)
+	parser.add_argument(
+		'--enable',
+		dest='modules_to_enable',
+		metavar='MODULE_TO_ENABLE',
+		help="enable specified module(s). use 'all' to enable all available modules.",
+		action='store',
+		nargs='+',
+		type=str,
+		choices=['all'] + list(MODULES.keys()),
+	)
+	parser.add_argument(
+		'--disable',
+		dest='module_to_disable',
+		help="disable specified module. after disable module files can be removed.",
+		action='store',
+		nargs=1,
+		type=str,
+		choices=MODULES.keys(),
+	)
+	parser.add_argument(
+		'--upgrade',
+		dest='modules_to_upgrade',
+		metavar='MODULE_TO_UPGRADE',
+		help="upgrade all or specified modules. if modules are not specified, then all modules will be upgraded.",
+		action='store',
+		nargs='*',
+		type=str,
+		choices=MODULES.keys(),
+	)
+
+	return parser
diff --git a/django_spinproject/constants.py b/django_spinproject/constants.py
new file mode 100644
index 0000000..ebaf355
--- /dev/null
+++ b/django_spinproject/constants.py
@@ -0,0 +1,1 @@
+DEFAULT_MAIN = 'main'
diff --git a/django_spinproject/generic/__init__.py b/django_spinproject/generic/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/django_spinproject/generic/compatibility_trait.py b/django_spinproject/generic/compatibility_trait.py
new file mode 100644
index 0000000..076abef
--- /dev/null
+++ b/django_spinproject/generic/compatibility_trait.py
@@ -0,0 +1,12 @@
+from typing import Sequence
+
+
+class CompatibilityTrait:
+	class_obj_attrs: Sequence[str] = ()
+
+	@classmethod
+	def is_compatible(cls, other: dict) -> bool:
+		"""
+		Checks the compatibility of the dictionary to the class object
+		"""
+		return len(set(other.keys()) & set(cls.class_obj_attrs)) == len(cls.class_obj_attrs)
diff --git a/django_spinproject/generic/dicts_merging.py b/django_spinproject/generic/dicts_merging.py
new file mode 100644
index 0000000..cc680e3
--- /dev/null
+++ b/django_spinproject/generic/dicts_merging.py
@@ -0,0 +1,33 @@
+def merge_dicts(a: dict, b: dict) -> dict:
+	"""
+	Merges two dictionaries by their keys, while the values are entered in the tuple.
+
+	Examples:
+		>>> a = {1: 1, 2: 2, 3: 3}
+		>>> b = {3: 3, 4: 4, 5: 5}
+		>>> merge_dicts(a, b)
+		{
+			1: (1, ),
+			2: (2, ),
+			3: (3, 3),
+			4: (4, ),
+			5: (5, ),
+		}
+
+	Notes:
+		Does not guarantee the order of keys.
+	"""
+	res = {}
+
+	for key in set(a.keys()) | set(b.keys()):
+		values = []
+
+		if key in a:
+			values.append(a[key])
+
+		if key in b:
+			values.append(b[key])
+
+		res[key] = tuple(values)
+
+	return res
diff --git a/django_spinproject/generic/directory_cleaning.py b/django_spinproject/generic/directory_cleaning.py
new file mode 100644
index 0000000..a5c62d5
--- /dev/null
+++ b/django_spinproject/generic/directory_cleaning.py
@@ -0,0 +1,27 @@
+import os
+from typing import Iterable
+
+
+def clean_dir(files_to_remove: Iterable[str], files_dir: str = '.', delete_empty_dir: bool = True) -> None:
+	"""
+	Deletes specified files in specified dir. If dir is empty after cleaning it will be deleted.
+
+	Args:
+		files_to_remove: Files to deleting.
+		files_dir: Dir name where files are stored.
+			Do not use the full path because it is set by default.
+		delete_empty_dir: Flag for deleting an empty directory after cleaning.
+	"""
+	files_path = os.path.join(os.getcwd(), files_dir)
+
+	for filename in files_to_remove:
+		file_path = os.path.join(files_path, filename)
+
+		if os.path.exists(file_path):
+			os.remove(file_path)
+
+		elif os.path.islink(file_path):
+			os.unlink(file_path)
+
+	if not os.listdir(files_path) and delete_empty_dir:
+		os.rmdir(files_path)
diff --git a/django_spinproject/generic/exit.py b/django_spinproject/generic/exit.py
new file mode 100644
index 0000000..524ade1
--- /dev/null
+++ b/django_spinproject/generic/exit.py
@@ -0,0 +1,6 @@
+import sys
+
+
+def exit_with_output(message: str, exit_code: int = 0):
+	print(message)
+	sys.exit(exit_code)
diff --git a/django_spinproject/generic/extended_jinja_environment.py b/django_spinproject/generic/extended_jinja_environment.py
new file mode 100644
index 0000000..579a2a5
--- /dev/null
+++ b/django_spinproject/generic/extended_jinja_environment.py
@@ -0,0 +1,24 @@
+from typing import Dict, Union
+
+from jinja2 import Environment, Template
+
+
+class ExtendedEnvironment(Environment):
+	def get_templates_dict(self, templates_as_strings: bool = False) -> Dict[str, Union[str, Template]]:
+		"""
+		Returns dictionary where keys are template names and values are templates.
+
+		Args:
+			templates_as_strings: Template conversion control flag.
+				If False - returns templates as Template class objects.
+				If True - returns templates as strings.
+		"""
+		res = {}
+
+		for template_name in self.list_templates():
+			if templates_as_strings:
+				res[template_name] = self.get_template(template_name).render()
+			else:
+				res[template_name] = self.get_template(template_name)
+
+		return res
diff --git a/django_spinproject/generic/file_upgrade.py b/django_spinproject/generic/file_upgrade.py
new file mode 100644
index 0000000..fb8b45b
--- /dev/null
+++ b/django_spinproject/generic/file_upgrade.py
@@ -0,0 +1,62 @@
+from .hash import get_file_hash, get_text_hash
+
+import os
+from typing import Sequence, Dict, Iterable
+
+
+def upgrade_files_content(files_dir: str, expected_content: Dict[str, Sequence[str]], new_content: Dict[str, str]):
+	"""
+	Upgrades content in specified files.
+
+	Args:
+		files_dir: Path to directory where the files should be updated.
+		expected_content: Dictionary of expected content in existing files.
+		new_content: Dictionary of new content in all files.
+
+	Raises:
+		ValueError: If at least one file is modified.
+
+	Notes:
+		If the file does not exist during the update, it will be added with the new content.
+	"""
+	check_existed_files(files_dir, expected_content)
+
+	if not os.path.exists(files_dir):
+		os.mkdir(files_dir)
+
+	for filename in new_content:
+		if files_dir[-1:] == '.':
+			files_dir = files_dir.rstrip('/.')
+
+		print(f"Writing {os.path.join(files_dir, filename)}...")
+		file_path = os.path.join(files_dir, filename)
+		write_file_content(file_path, new_content[filename])
+
+
+def check_existed_files(files_dir: str, expected_content: Dict[str, Sequence[str]]):
+	if not os.path.exists(files_dir):
+		return
+
+	for filename in expected_content:
+		file_path = os.path.join(files_dir, filename)
+
+		check_file_content(file_path, expected_content[filename])
+
+
+def check_file_content(file_path: str, expected_content: Iterable[str]) -> None:
+	if os.path.exists(file_path):
+		existed_file_hash = get_existed_file_hash(file_path)
+		expected_file_hashes = tuple(map(get_text_hash, expected_content))
+
+		if existed_file_hash not in expected_file_hashes:
+			raise ValueError(f"The file already exists with a different content. File: {file_path}")
+
+
+def get_existed_file_hash(file_path: str) -> str:
+	with open(file_path, mode='rb') as file:
+		return get_file_hash(file)
+
+
+def write_file_content(file_path: str, content: str) -> None:
+	with open(file_path, mode='w') as file:
+		file.write(content)
diff --git a/django_spinproject/generic/hash.py b/django_spinproject/generic/hash.py
new file mode 100644
index 0000000..aad88f4
--- /dev/null
+++ b/django_spinproject/generic/hash.py
@@ -0,0 +1,35 @@
+from hashlib import sha1
+
+
+HASH_BLOCK_SIZE = 65536
+
+
+def get_file_hash(file) -> str:
+	"""
+	Returns file hash as string.
+
+	Notes:
+		Changing the hashing function in production will lead to failures.
+	"""
+	hash_function = sha1()
+	buffer = file.read(HASH_BLOCK_SIZE)
+
+	while len(buffer) > 0:
+		hash_function.update(buffer)
+		buffer = file.read(HASH_BLOCK_SIZE)
+
+	return str(hash_function.hexdigest())
+
+
+def get_text_hash(text: str, encoding: str = 'utf-8') -> str:
+	"""
+	Returns text hash as string.
+
+	Args:
+		text: Source text to get the hash.
+		encoding: Source text encoding. utf-8 by default.
+
+	Notes:
+		Changing the hashing function in production will lead to failures.
+	"""
+	return str(sha1(text.encode(encoding)).hexdigest())
diff --git a/django_spinproject/generic/serializable_obj.py b/django_spinproject/generic/serializable_obj.py
new file mode 100644
index 0000000..198485e
--- /dev/null
+++ b/django_spinproject/generic/serializable_obj.py
@@ -0,0 +1,14 @@
+class SerializationTrait:
+	"""
+	Allows to serialize objects, including nested data structures if they are inherited from this class.
+	"""
+	def serialize(self) -> dict:
+		serialized_obj = {}
+
+		for key, value in self.__dict__.items():
+			if isinstance(value, SerializationTrait):
+				serialized_obj[key] = value.serialize()
+			else:
+				serialized_obj[key] = value
+
+		return serialized_obj
diff --git a/django_spinproject/modules/__init__.py b/django_spinproject/modules/__init__.py
new file mode 100644
index 0000000..1fa1abc
--- /dev/null
+++ b/django_spinproject/modules/__init__.py
@@ -0,0 +1,22 @@
+from .gitignore import GitignoreModule
+from .srta import SRTAModule
+from .pytest import PytestModule
+from .dockerfile import DockerfileModule
+from .dockerignore import DockerignoreModule
+from .docker_scripts import DockerScriptsModule
+from .gitlab_ci import GitlabCIModule
+from .pg_readonly import PGReadonlyModule
+from .settings import SettingsModule
+
+
+MODULES = {
+	GitignoreModule.name: GitignoreModule,
+	SRTAModule.name: SRTAModule,
+	PytestModule.name: PytestModule,
+	DockerfileModule.name: DockerfileModule,
+	DockerignoreModule.name: DockerignoreModule,
+	DockerScriptsModule.name: DockerScriptsModule,
+	GitlabCIModule.name: GitlabCIModule,
+	PGReadonlyModule.name: PGReadonlyModule,
+	SettingsModule.name: SettingsModule,
+}
diff --git a/django_spinproject/modules/_base.py b/django_spinproject/modules/_base.py
new file mode 100644
index 0000000..a6d5576
--- /dev/null
+++ b/django_spinproject/modules/_base.py
@@ -0,0 +1,90 @@
+from ..generic.dicts_merging import merge_dicts
+from ..project_manager.project_info import ProjectInfo
+from ..generic.extended_jinja_environment import ExtendedEnvironment
+from ..generic.file_upgrade import upgrade_files_content
+from ..generic.directory_cleaning import clean_dir
+
+from typing import Sequence, Dict, Tuple
+
+from jinja2 import Template
+
+
+class BaseModule:
+	name = ''
+	help_text = ""
+	environments: Sequence[ExtendedEnvironment] = ()
+	files_dir = '.'
+
+	@classmethod
+	def last_version(cls) -> int:
+		"""
+		Returns module last version number.
+		"""
+		return len(cls.environments)
+
+	@classmethod
+	def upgrade_step(cls, current_version: int, project_info: ProjectInfo) -> None:
+		"""
+		Upgrades module in project.
+		"""
+		cls._before_upgrade(current_version, project_info)
+		cls._upgrade_step(current_version, project_info)
+		cls._after_upgrade(current_version, project_info)
+
+	@classmethod
+	def _before_upgrade(cls, current_version: int, project_info: ProjectInfo) -> None:
+		"""
+		Contains the logic executed before upgrading the module.
+		"""
+		pass
+
+	@classmethod
+	def _upgrade_step(cls, current_version: int, project_info: ProjectInfo, **render_kwargs) -> None:
+		"""
+		Contains the module upgrade logic.
+		"""
+		content, expected_content = {}, {}
+
+		for filename, template in cls.environments[current_version].get_templates_dict().items():
+			content[filename] = template.render(render_kwargs)
+
+		for filename, templates in cls.get_expected_templates(current_version).items():
+			expected_content[filename] = tuple(i.render(render_kwargs) for i in templates)
+
+		upgrade_files_content(cls.get_full_files_dir(project_info), expected_content, content)
+
+	@classmethod
+	def _after_upgrade(cls, current_version: int, project_info: ProjectInfo) -> None:
+		"""
+		Contains the logic executed after upgrading the module.
+		"""
+		pass
+
+	@classmethod
+	def cleanup(cls, current_version: int, project_info: ProjectInfo) -> None:
+		"""
+		Deletes module files.
+		"""
+		if current_version > 0:
+			clean_dir(cls.environments[current_version - 1].list_templates(), cls.get_full_files_dir(project_info))
+
+	@classmethod
+	def get_expected_templates(cls, current_version: int) -> Dict[str, Tuple[Template]]:
+		"""
+		Returns templates of expected files on the current version of the module.
+		"""
+		if current_version == 0:
+			templates_dict = cls.environments[current_version].get_templates_dict()
+			return {filename: (template,) for filename, template in templates_dict.items()}
+
+		return merge_dicts(
+			cls.environments[current_version - 1].get_templates_dict(),
+			cls.environments[current_version].get_templates_dict(),
+		)
+
+	@classmethod
+	def get_full_files_dir(cls, project_info: ProjectInfo) -> str:
+		"""
+		Returns the full, but not absolute, path to the module files. The root is the project dir.
+		"""
+		return cls.files_dir
diff --git a/django_spinproject/modules/_mixins.py b/django_spinproject/modules/_mixins.py
new file mode 100644
index 0000000..213375e
--- /dev/null
+++ b/django_spinproject/modules/_mixins.py
@@ -0,0 +1,25 @@
+"""
+Contains mixins for BaseModule.
+"""
+from ..generic.extended_jinja_environment import ExtendedEnvironment
+
+import os
+import shlex
+import subprocess
+from typing import Sequence
+
+
+class ChmodMixin:
+	files_dir = '.'
+	environments: Sequence[ExtendedEnvironment] = ()
+
+	@classmethod
+	def change_files_mode(cls, current_version: int):
+		"""
+		Changes the files mode to "executable".
+		"""
+		cwd = os.getcwd()
+
+		for filename in cls.environments[current_version - 1].list_templates():
+			full_file_path = os.path.join(cwd, cls.files_dir, filename)
+			subprocess.run(f"chmod +x {shlex.quote(full_file_path)}", shell=True)
diff --git a/django_spinproject/modules/docker_scripts.py b/django_spinproject/modules/docker_scripts.py
new file mode 100644
index 0000000..604aaa3
--- /dev/null
+++ b/django_spinproject/modules/docker_scripts.py
@@ -0,0 +1,20 @@
+from ._base import BaseModule
+from ._mixins import ChmodMixin
+from .docker_scripts_data import _V1_ENV
+from ..project_manager.project_info import ProjectInfo
+
+
+class DockerScriptsModule(BaseModule, ChmodMixin):
+	name = 'docker-scripts'
+	help_text = "Creates scripts for building and pushing docker image"
+	environments = (_V1_ENV, )
+	files_dir = 'script'
+
+	@classmethod
+	def _upgrade_step(cls, current_version: int, project_info: ProjectInfo, **render_kwargs) -> None:
+		render_kwargs.update(project_info.config.docker.__dict__.copy())
+		super()._upgrade_step(current_version, project_info, **render_kwargs)
+
+	@classmethod
+	def _after_upgrade(cls, current_version: int, project_info: ProjectInfo) -> None:
+		cls.change_files_mode(current_version)
diff --git a/django_spinproject/modules/docker_scripts_data/__init__.py b/django_spinproject/modules/docker_scripts_data/__init__.py
new file mode 100644
index 0000000..5b3d1c1
--- /dev/null
+++ b/django_spinproject/modules/docker_scripts_data/__init__.py
@@ -0,0 +1,7 @@
+from .v1 import _CONTENT as _V1_CONTENT
+from ...generic.extended_jinja_environment import ExtendedEnvironment
+
+from jinja2 import DictLoader
+
+
+_V1_ENV = ExtendedEnvironment(loader=DictLoader(_V1_CONTENT))
diff --git a/django_spinproject/modules/docker_scripts_data/v1.py b/django_spinproject/modules/docker_scripts_data/v1.py
new file mode 100644
index 0000000..f19ab9f
--- /dev/null
+++ b/django_spinproject/modules/docker_scripts_data/v1.py
@@ -0,0 +1,14 @@
+_CONTENT = {
+	'x-dockerbuild': """#!/bin/bash
+set -e
+set -x
+
+docker build -t '{{ repository }}{% if repository %}/{% endif %}{{ image }}:{{ tag }}' .
+""",
+	'x-dockerpush': """#!/bin/bash
+set -e
+set -x
+
+docker push '{{ repository }}{% if repository %}/{% endif %}{{ image }}:{{ tag }}'
+""",
+}
diff --git a/django_spinproject/modules/dockerfile.py b/django_spinproject/modules/dockerfile.py
new file mode 100644
index 0000000..663a722
--- /dev/null
+++ b/django_spinproject/modules/dockerfile.py
@@ -0,0 +1,14 @@
+from ._base import BaseModule
+from .dockerfile_data import _V1_ENV, _V2_ENV
+from ..project_manager.project_info import ProjectInfo
+
+
+class DockerfileModule(BaseModule):
+	name = 'dockerfile'
+	help_text = "Creates Dockerfile"
+	environments = (_V1_ENV, _V2_ENV,)
+
+	@classmethod
+	def _upgrade_step(cls, current_version: int, project_info: ProjectInfo, **render_kwargs) -> None:
+		render_kwargs['name'] = project_info.config.main
+		super()._upgrade_step(current_version, project_info, **render_kwargs)
diff --git a/django_spinproject/modules/dockerfile_data/__init__.py b/django_spinproject/modules/dockerfile_data/__init__.py
new file mode 100644
index 0000000..325543e
--- /dev/null
+++ b/django_spinproject/modules/dockerfile_data/__init__.py
@@ -0,0 +1,9 @@
+from .v1 import _CONTENT as _V1_CONTENT
+from .v2 import _CONTENT as _V2_CONTENT
+from ...generic.extended_jinja_environment import ExtendedEnvironment
+
+from jinja2 import DictLoader
+
+
+_V1_ENV = ExtendedEnvironment(loader=DictLoader(_V1_CONTENT))
+_V2_ENV = ExtendedEnvironment(loader=DictLoader(_V2_CONTENT))
diff --git a/django_spinproject/modules/dockerfile_data/v1.py b/django_spinproject/modules/dockerfile_data/v1.py
new file mode 100644
index 0000000..35ecbe2
--- /dev/null
+++ b/django_spinproject/modules/dockerfile_data/v1.py
@@ -0,0 +1,30 @@
+_CONTENT = {
+	'Dockerfile': '''FROM alang/django:2.1-python3
+
+# --allow-releaseinfo-change because buster is now oldstable
+RUN apt-get update --allow-releaseinfo-change \\
+    && apt-get install -y --no-install-recommends \\
+        postgresql-client \\
+        mariadb-client \\
+        nano \\
+        ruby-foreman \\
+    && rm -rf /var/lib/apt/lists/*
+
+RUN pip install poetry
+RUN poetry config virtualenvs.create false
+
+COPY pyproject.toml ./
+COPY poetry.lock ./
+RUN poetry install
+
+ENV DJANGO_SETTINGS_MODULE {{ name }}.settings
+ENV DJANGO_APP={{ name }}
+
+ENV GUNICORN_CMD_ARGS ""
+# If you prefer to set gunicorn options in Dockerfile, it's done like this:
+#ENV GUNICORN_CMD_ARGS "-t 600 -w1"
+
+ENV DJANGO_MANAGEMENT_ON_START "migrate; collectstatic --noinput"
+
+COPY . /usr/django/app'''
+}
diff --git a/django_spinproject/modules/dockerfile_data/v2.py b/django_spinproject/modules/dockerfile_data/v2.py
new file mode 100644
index 0000000..5ddb329
--- /dev/null
+++ b/django_spinproject/modules/dockerfile_data/v2.py
@@ -0,0 +1,30 @@
+_CONTENT = {
+	'Dockerfile': '''FROM python:3.9.6-slim-bullseye
+
+# --allow-releaseinfo-change for cases when stable becomes oldstable
+RUN apt-get update --allow-releaseinfo-change \\
+    && apt-get install -y --no-install-recommends \\
+        postgresql-client \\
+        mariadb-client \\
+        nano \\
+        ruby-foreman \\
+    && rm -rf /var/lib/apt/lists/*
+
+RUN pip install poetry
+RUN poetry config virtualenvs.create false
+
+COPY pyproject.toml ./
+COPY poetry.lock ./
+RUN poetry install
+
+ENV DJANGO_SETTINGS_MODULE {{ name }}.settings
+ENV DJANGO_APP={{ name }}
+
+#ENV GUNICORN_CMD_ARGS "-t 600 -w1"
+ENV GUNICORN_CMD_ARGS ""
+
+WORKDIR "/usr/django/app"
+CMD ["sh", "-c", "./manage.py migrate && ./manage.py createcachetable && ./manage.py collectstatic --noinput && gunicorn -b 0.0.0.0:8000 -t 600 --workers 1 --threads 20 main.wsgi"]
+
+COPY . /usr/django/app'''
+}
diff --git a/django_spinproject/modules/dockerignore.py b/django_spinproject/modules/dockerignore.py
new file mode 100644
index 0000000..cb9e8aa
--- /dev/null
+++ b/django_spinproject/modules/dockerignore.py
@@ -0,0 +1,8 @@
+from ._base import BaseModule
+from .dockerignore_data import _V1_ENV, _V2_ENV, _V3_ENV
+
+
+class DockerignoreModule(BaseModule):
+	name = 'dockerignore'
+	help_text = "Creates .dockerignore file"
+	environments = (_V1_ENV, _V2_ENV, _V3_ENV)
diff --git a/django_spinproject/modules/dockerignore_data/__init__.py b/django_spinproject/modules/dockerignore_data/__init__.py
new file mode 100644
index 0000000..c61b40b
--- /dev/null
+++ b/django_spinproject/modules/dockerignore_data/__init__.py
@@ -0,0 +1,11 @@
+from .v1 import _CONTENT as _V1_CONTENT
+from .v2 import _CONTENT as _V2_CONTENT
+from .v3 import _CONTENT as _V3_CONTENT
+from ...generic.extended_jinja_environment import ExtendedEnvironment
+
+from jinja2 import DictLoader
+
+
+_V1_ENV = ExtendedEnvironment(loader=DictLoader(_V1_CONTENT))
+_V2_ENV = ExtendedEnvironment(loader=DictLoader(_V2_CONTENT))
+_V3_ENV = ExtendedEnvironment(loader=DictLoader(_V3_CONTENT))
diff --git a/django_spinproject/modules/dockerignore_data/v1.py b/django_spinproject/modules/dockerignore_data/v1.py
new file mode 100644
index 0000000..8c4d762
--- /dev/null
+++ b/django_spinproject/modules/dockerignore_data/v1.py
@@ -0,0 +1,25 @@
+_CONTENT = {
+	'.dockerignore': '''**/__pycache__
+**/.classpath
+**/.dockerignore
+**/.env
+**/.git
+**/.gitignore
+**/.project
+**/.settings
+**/.toolstarget
+**/.vs
+**/.vscode
+**/*.*proj.user
+**/*.dbmdl
+**/*.jfm
+**/azds.yaml
+**/charts
+**/docker-compose*
+**/Dockerfile*
+**/node_modules
+**/npm-debug.log
+**/obj
+**/secrets.dev.yaml
+**/values.dev.yaml'''
+}
\ No newline at end of file
diff --git a/django_spinproject/modules/dockerignore_data/v2.py b/django_spinproject/modules/dockerignore_data/v2.py
new file mode 100644
index 0000000..d9a83f2
--- /dev/null
+++ b/django_spinproject/modules/dockerignore_data/v2.py
@@ -0,0 +1,26 @@
+_CONTENT = {
+	'.dockerignore': '''**/__pycache__
+**/.cache
+**/.classpath
+**/.dockerignore
+**/.env
+**/.git
+**/.gitignore
+**/.project
+**/.settings
+**/.toolstarget
+**/.vs
+**/.vscode
+**/*.*proj.user
+**/*.dbmdl
+**/*.jfm
+**/azds.yaml
+**/charts
+**/docker-compose*
+**/Dockerfile*
+**/node_modules
+**/npm-debug.log
+**/obj
+**/secrets.dev.yaml
+**/values.dev.yaml'''
+}
diff --git a/django_spinproject/modules/dockerignore_data/v3.py b/django_spinproject/modules/dockerignore_data/v3.py
new file mode 100644
index 0000000..4b6e828
--- /dev/null
+++ b/django_spinproject/modules/dockerignore_data/v3.py
@@ -0,0 +1,29 @@
+_CONTENT = {
+	'.dockerignore': '''*.sqlite3
+*.sqlite3-journal
+
+**/__pycache__
+**/.cache
+**/.classpath
+**/.dockerignore
+**/.env
+**/.git
+**/.gitignore
+**/.project
+**/.settings
+**/.toolstarget
+**/.vs
+**/.vscode
+**/*.*proj.user
+**/*.dbmdl
+**/*.jfm
+**/azds.yaml
+**/charts
+**/docker-compose*
+**/Dockerfile*
+**/node_modules
+**/npm-debug.log
+**/obj
+**/secrets.dev.yaml
+**/values.dev.yaml'''
+}
diff --git a/django_spinproject/modules/gitignore.py b/django_spinproject/modules/gitignore.py
new file mode 100644
index 0000000..349c1a8
--- /dev/null
+++ b/django_spinproject/modules/gitignore.py
@@ -0,0 +1,8 @@
+from ._base import BaseModule
+from .gitignore_data import _V1_ENV, _V2_ENV, _V3_ENV
+
+
+class GitignoreModule(BaseModule):
+	name = 'gitignore'
+	help_text = "Creates .gitignore file"
+	environments = (_V1_ENV, _V2_ENV, _V3_ENV)
diff --git a/django_spinproject/modules/gitignore_data/__init__.py b/django_spinproject/modules/gitignore_data/__init__.py
new file mode 100644
index 0000000..c61b40b
--- /dev/null
+++ b/django_spinproject/modules/gitignore_data/__init__.py
@@ -0,0 +1,11 @@
+from .v1 import _CONTENT as _V1_CONTENT
+from .v2 import _CONTENT as _V2_CONTENT
+from .v3 import _CONTENT as _V3_CONTENT
+from ...generic.extended_jinja_environment import ExtendedEnvironment
+
+from jinja2 import DictLoader
+
+
+_V1_ENV = ExtendedEnvironment(loader=DictLoader(_V1_CONTENT))
+_V2_ENV = ExtendedEnvironment(loader=DictLoader(_V2_CONTENT))
+_V3_ENV = ExtendedEnvironment(loader=DictLoader(_V3_CONTENT))
diff --git a/django_spinproject/modules/gitignore_data/v1.py b/django_spinproject/modules/gitignore_data/v1.py
new file mode 100644
index 0000000..74d12ed
--- /dev/null
+++ b/django_spinproject/modules/gitignore_data/v1.py
@@ -0,0 +1,360 @@
+_CONTENT = {
+	'.gitignore': """.env
+*~
+
+
+# Created by https://www.toptal.com/developers/gitignore/api/django,python,code,sublimetext,pycharm
+# Edit at https://www.toptal.com/developers/gitignore?templates=django,python,code,sublimetext,pycharm
+
+### Code ###
+.vscode/*
+!.vscode/tasks.json
+!.vscode/launch.json
+*.code-workspace
+
+### Django ###
+*.log
+*.pot
+*.pyc
+__pycache__/
+local_settings.py
+db.sqlite3
+db.sqlite3-journal
+media
+
+# If your build process includes running collectstatic, then you probably don't need or want to include staticfiles/
+# in your Git repository. Update and uncomment the following line accordingly.
+# <django-project-name>/staticfiles/
+
+### Django.Python Stack ###
+# Byte-compiled / optimized / DLL files
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+pip-wheel-metadata/
+share/python-wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+MANIFEST
+
+# PyInstaller
+#  Usually these files are written by a python script from a template
+#  before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.nox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+*.py,cover
+.hypothesis/
+.pytest_cache/
+pytestdebug.log
+
+# Translations
+*.mo
+
+# Django stuff:
+
+# Flask stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+doc/_build/
+
+# PyBuilder
+target/
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# IPython
+profile_default/
+ipython_config.py
+
+# pyenv
+.python-version
+
+# pipenv
+#   According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
+#   However, in case of collaboration, if having platform-specific dependencies or dependencies
+#   having no cross-platform support, pipenv may install dependencies that don't work, or not
+#   install all needed dependencies.
+#Pipfile.lock
+
+# PEP 582; used by e.g. github.com/David-OConnor/pyflow
+__pypackages__/
+
+# Celery stuff
+celerybeat-schedule
+celerybeat.pid
+
+# SageMath parsed files
+*.sage.py
+
+# Environments
+.env
+.venv
+env/
+venv/
+ENV/
+env.bak/
+venv.bak/
+pythonenv*
+
+# Spyder project settings
+.spyderproject
+.spyproject
+
+# Rope project settings
+.ropeproject
+
+# mkdocs documentation
+/site
+
+# mypy
+.mypy_cache/
+.dmypy.json
+dmypy.json
+
+# Pyre type checker
+.pyre/
+
+# pytype static type analyzer
+.pytype/
+
+# profiling data
+.prof
+
+### PyCharm ###
+# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
+# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
+
+# User-specific stuff
+.idea/**/workspace.xml
+.idea/**/tasks.xml
+.idea/**/usage.statistics.xml
+.idea/**/dictionaries
+.idea/**/shelf
+
+# Generated files
+.idea/**/contentModel.xml
+
+# Sensitive or high-churn files
+.idea/**/dataSources/
+.idea/**/dataSources.ids
+.idea/**/dataSources.local.xml
+.idea/**/sqlDataSources.xml
+.idea/**/dynamic.xml
+.idea/**/uiDesigner.xml
+.idea/**/dbnavigator.xml
+
+# Gradle
+.idea/**/gradle.xml
+.idea/**/libraries
+
+# Gradle and Maven with auto-import
+# When using Gradle or Maven with auto-import, you should exclude module files,
+# since they will be recreated, and may cause churn.  Uncomment if using
+# auto-import.
+# .idea/artifacts
+# .idea/compiler.xml
+# .idea/jarRepositories.xml
+# .idea/modules.xml
+# .idea/*.iml
+# .idea/modules
+# *.iml
+# *.ipr
+
+# CMake
+cmake-build-*/
+
+# Mongo Explorer plugin
+.idea/**/mongoSettings.xml
+
+# File-based project format
+*.iws
+
+# IntelliJ
+out/
+
+# mpeltonen/sbt-idea plugin
+.idea_modules/
+
+# JIRA plugin
+atlassian-ide-plugin.xml
+
+# Cursive Clojure plugin
+.idea/replstate.xml
+
+# Crashlytics plugin (for Android Studio and IntelliJ)
+com_crashlytics_export_strings.xml
+crashlytics.properties
+crashlytics-build.properties
+fabric.properties
+
+# Editor-based Rest Client
+.idea/httpRequests
+
+# Android studio 3.1+ serialized cache file
+.idea/caches/build_file_checksums.ser
+
+### PyCharm Patch ###
+# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721
+
+# *.iml
+# modules.xml
+# .idea/misc.xml
+# *.ipr
+
+# Sonarlint plugin
+# https://plugins.jetbrains.com/plugin/7973-sonarlint
+.idea/**/sonarlint/
+
+# SonarQube Plugin
+# https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin
+.idea/**/sonarIssues.xml
+
+# Markdown Navigator plugin
+# https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced
+.idea/**/markdown-navigator.xml
+.idea/**/markdown-navigator-enh.xml
+.idea/**/markdown-navigator/
+
+# Cache file creation bug
+# See https://youtrack.jetbrains.com/issue/JBR-2257
+.idea/$CACHE_FILE$
+
+# CodeStream plugin
+# https://plugins.jetbrains.com/plugin/12206-codestream
+.idea/codestream.xml
+
+### Python ###
+# Byte-compiled / optimized / DLL files
+
+# C extensions
+
+# Distribution / packaging
+
+# PyInstaller
+#  Usually these files are written by a python script from a template
+#  before PyInstaller builds the exe, so as to inject date/other infos into it.
+
+# Installer logs
+
+# Unit test / coverage reports
+
+# Translations
+
+# Django stuff:
+
+# Flask stuff:
+
+# Scrapy stuff:
+
+# Sphinx documentation
+
+# PyBuilder
+
+# Jupyter Notebook
+
+# IPython
+
+# pyenv
+
+# pipenv
+#   According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
+#   However, in case of collaboration, if having platform-specific dependencies or dependencies
+#   having no cross-platform support, pipenv may install dependencies that don't work, or not
+#   install all needed dependencies.
+
+# PEP 582; used by e.g. github.com/David-OConnor/pyflow
+
+# Celery stuff
+
+# SageMath parsed files
+
+# Environments
+
+# Spyder project settings
+
+# Rope project settings
+
+# mkdocs documentation
+
+# mypy
+
+# Pyre type checker
+
+# pytype static type analyzer
+
+# profiling data
+
+### SublimeText ###
+# Cache files for Sublime Text
+*.tmlanguage.cache
+*.tmPreferences.cache
+*.stTheme.cache
+
+# Workspace files are user-specific
+*.sublime-workspace
+
+# Project files should be checked into the repository, unless a significant
+# proportion of contributors will probably not be using Sublime Text
+# *.sublime-project
+
+# SFTP configuration file
+sftp-config.json
+
+# Package control specific files
+Package Control.last-run
+Package Control.ca-list
+Package Control.ca-bundle
+Package Control.system-ca-bundle
+Package Control.cache/
+Package Control.ca-certs/
+Package Control.merged-ca-bundle
+Package Control.user-ca-bundle
+oscrypto-ca-bundle.crt
+bh_unicode_properties.cache
+
+# Sublime-github package stores a github token in this file
+# https://packagecontrol.io/packages/sublime-github
+GitHub.sublime-settings
+
+# End of https://www.toptal.com/developers/gitignore/api/django,python,code,sublimetext,pycharm""",
+}
diff --git a/django_spinproject/modules/gitignore_data/v2.py b/django_spinproject/modules/gitignore_data/v2.py
new file mode 100644
index 0000000..e9b96de
--- /dev/null
+++ b/django_spinproject/modules/gitignore_data/v2.py
@@ -0,0 +1,361 @@
+_CONTENT = {
+	'.gitignore': """.env
+.cache/
+*~
+
+
+# Created by https://www.toptal.com/developers/gitignore/api/django,python,code,sublimetext,pycharm
+# Edit at https://www.toptal.com/developers/gitignore?templates=django,python,code,sublimetext,pycharm
+
+### Code ###
+.vscode/*
+!.vscode/tasks.json
+!.vscode/launch.json
+*.code-workspace
+
+### Django ###
+*.log
+*.pot
+*.pyc
+__pycache__/
+local_settings.py
+db.sqlite3
+db.sqlite3-journal
+media
+
+# If your build process includes running collectstatic, then you probably don't need or want to include staticfiles/
+# in your Git repository. Update and uncomment the following line accordingly.
+# <django-project-name>/staticfiles/
+
+### Django.Python Stack ###
+# Byte-compiled / optimized / DLL files
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+pip-wheel-metadata/
+share/python-wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+MANIFEST
+
+# PyInstaller
+#  Usually these files are written by a python script from a template
+#  before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.nox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+*.py,cover
+.hypothesis/
+.pytest_cache/
+pytestdebug.log
+
+# Translations
+*.mo
+
+# Django stuff:
+
+# Flask stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+doc/_build/
+
+# PyBuilder
+target/
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# IPython
+profile_default/
+ipython_config.py
+
+# pyenv
+.python-version
+
+# pipenv
+#   According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
+#   However, in case of collaboration, if having platform-specific dependencies or dependencies
+#   having no cross-platform support, pipenv may install dependencies that don't work, or not
+#   install all needed dependencies.
+#Pipfile.lock
+
+# PEP 582; used by e.g. github.com/David-OConnor/pyflow
+__pypackages__/
+
+# Celery stuff
+celerybeat-schedule
+celerybeat.pid
+
+# SageMath parsed files
+*.sage.py
+
+# Environments
+.env
+.venv
+env/
+venv/
+ENV/
+env.bak/
+venv.bak/
+pythonenv*
+
+# Spyder project settings
+.spyderproject
+.spyproject
+
+# Rope project settings
+.ropeproject
+
+# mkdocs documentation
+/site
+
+# mypy
+.mypy_cache/
+.dmypy.json
+dmypy.json
+
+# Pyre type checker
+.pyre/
+
+# pytype static type analyzer
+.pytype/
+
+# profiling data
+.prof
+
+### PyCharm ###
+# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
+# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
+
+# User-specific stuff
+.idea/**/workspace.xml
+.idea/**/tasks.xml
+.idea/**/usage.statistics.xml
+.idea/**/dictionaries
+.idea/**/shelf
+
+# Generated files
+.idea/**/contentModel.xml
+
+# Sensitive or high-churn files
+.idea/**/dataSources/
+.idea/**/dataSources.ids
+.idea/**/dataSources.local.xml
+.idea/**/sqlDataSources.xml
+.idea/**/dynamic.xml
+.idea/**/uiDesigner.xml
+.idea/**/dbnavigator.xml
+
+# Gradle
+.idea/**/gradle.xml
+.idea/**/libraries
+
+# Gradle and Maven with auto-import
+# When using Gradle or Maven with auto-import, you should exclude module files,
+# since they will be recreated, and may cause churn.  Uncomment if using
+# auto-import.
+# .idea/artifacts
+# .idea/compiler.xml
+# .idea/jarRepositories.xml
+# .idea/modules.xml
+# .idea/*.iml
+# .idea/modules
+# *.iml
+# *.ipr
+
+# CMake
+cmake-build-*/
+
+# Mongo Explorer plugin
+.idea/**/mongoSettings.xml
+
+# File-based project format
+*.iws
+
+# IntelliJ
+out/
+
+# mpeltonen/sbt-idea plugin
+.idea_modules/
+
+# JIRA plugin
+atlassian-ide-plugin.xml
+
+# Cursive Clojure plugin
+.idea/replstate.xml
+
+# Crashlytics plugin (for Android Studio and IntelliJ)
+com_crashlytics_export_strings.xml
+crashlytics.properties
+crashlytics-build.properties
+fabric.properties
+
+# Editor-based Rest Client
+.idea/httpRequests
+
+# Android studio 3.1+ serialized cache file
+.idea/caches/build_file_checksums.ser
+
+### PyCharm Patch ###
+# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721
+
+# *.iml
+# modules.xml
+# .idea/misc.xml
+# *.ipr
+
+# Sonarlint plugin
+# https://plugins.jetbrains.com/plugin/7973-sonarlint
+.idea/**/sonarlint/
+
+# SonarQube Plugin
+# https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin
+.idea/**/sonarIssues.xml
+
+# Markdown Navigator plugin
+# https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced
+.idea/**/markdown-navigator.xml
+.idea/**/markdown-navigator-enh.xml
+.idea/**/markdown-navigator/
+
+# Cache file creation bug
+# See https://youtrack.jetbrains.com/issue/JBR-2257
+.idea/$CACHE_FILE$
+
+# CodeStream plugin
+# https://plugins.jetbrains.com/plugin/12206-codestream
+.idea/codestream.xml
+
+### Python ###
+# Byte-compiled / optimized / DLL files
+
+# C extensions
+
+# Distribution / packaging
+
+# PyInstaller
+#  Usually these files are written by a python script from a template
+#  before PyInstaller builds the exe, so as to inject date/other infos into it.
+
+# Installer logs
+
+# Unit test / coverage reports
+
+# Translations
+
+# Django stuff:
+
+# Flask stuff:
+
+# Scrapy stuff:
+
+# Sphinx documentation
+
+# PyBuilder
+
+# Jupyter Notebook
+
+# IPython
+
+# pyenv
+
+# pipenv
+#   According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
+#   However, in case of collaboration, if having platform-specific dependencies or dependencies
+#   having no cross-platform support, pipenv may install dependencies that don't work, or not
+#   install all needed dependencies.
+
+# PEP 582; used by e.g. github.com/David-OConnor/pyflow
+
+# Celery stuff
+
+# SageMath parsed files
+
+# Environments
+
+# Spyder project settings
+
+# Rope project settings
+
+# mkdocs documentation
+
+# mypy
+
+# Pyre type checker
+
+# pytype static type analyzer
+
+# profiling data
+
+### SublimeText ###
+# Cache files for Sublime Text
+*.tmlanguage.cache
+*.tmPreferences.cache
+*.stTheme.cache
+
+# Workspace files are user-specific
+*.sublime-workspace
+
+# Project files should be checked into the repository, unless a significant
+# proportion of contributors will probably not be using Sublime Text
+# *.sublime-project
+
+# SFTP configuration file
+sftp-config.json
+
+# Package control specific files
+Package Control.last-run
+Package Control.ca-list
+Package Control.ca-bundle
+Package Control.system-ca-bundle
+Package Control.cache/
+Package Control.ca-certs/
+Package Control.merged-ca-bundle
+Package Control.user-ca-bundle
+oscrypto-ca-bundle.crt
+bh_unicode_properties.cache
+
+# Sublime-github package stores a github token in this file
+# https://packagecontrol.io/packages/sublime-github
+GitHub.sublime-settings
+
+# End of https://www.toptal.com/developers/gitignore/api/django,python,code,sublimetext,pycharm""",
+}
diff --git a/django_spinproject/modules/gitignore_data/v3.py b/django_spinproject/modules/gitignore_data/v3.py
new file mode 100644
index 0000000..9e9f1a1
--- /dev/null
+++ b/django_spinproject/modules/gitignore_data/v3.py
@@ -0,0 +1,363 @@
+_CONTENT = {
+	'.gitignore': """.env
+.cache/
+*~
+
+static/
+
+
+# Created by https://www.toptal.com/developers/gitignore/api/django,python,code,sublimetext,pycharm
+# Edit at https://www.toptal.com/developers/gitignore?templates=django,python,code,sublimetext,pycharm
+
+### Code ###
+.vscode/*
+!.vscode/tasks.json
+!.vscode/launch.json
+*.code-workspace
+
+### Django ###
+*.log
+*.pot
+*.pyc
+__pycache__/
+local_settings.py
+db.sqlite3
+db.sqlite3-journal
+media
+
+# If your build process includes running collectstatic, then you probably don't need or want to include staticfiles/
+# in your Git repository. Update and uncomment the following line accordingly.
+# <django-project-name>/staticfiles/
+
+### Django.Python Stack ###
+# Byte-compiled / optimized / DLL files
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+pip-wheel-metadata/
+share/python-wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+MANIFEST
+
+# PyInstaller
+#  Usually these files are written by a python script from a template
+#  before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.nox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+*.py,cover
+.hypothesis/
+.pytest_cache/
+pytestdebug.log
+
+# Translations
+*.mo
+
+# Django stuff:
+
+# Flask stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+doc/_build/
+
+# PyBuilder
+target/
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# IPython
+profile_default/
+ipython_config.py
+
+# pyenv
+.python-version
+
+# pipenv
+#   According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
+#   However, in case of collaboration, if having platform-specific dependencies or dependencies
+#   having no cross-platform support, pipenv may install dependencies that don't work, or not
+#   install all needed dependencies.
+#Pipfile.lock
+
+# PEP 582; used by e.g. github.com/David-OConnor/pyflow
+__pypackages__/
+
+# Celery stuff
+celerybeat-schedule
+celerybeat.pid
+
+# SageMath parsed files
+*.sage.py
+
+# Environments
+.env
+.venv
+env/
+venv/
+ENV/
+env.bak/
+venv.bak/
+pythonenv*
+
+# Spyder project settings
+.spyderproject
+.spyproject
+
+# Rope project settings
+.ropeproject
+
+# mkdocs documentation
+/site
+
+# mypy
+.mypy_cache/
+.dmypy.json
+dmypy.json
+
+# Pyre type checker
+.pyre/
+
+# pytype static type analyzer
+.pytype/
+
+# profiling data
+.prof
+
+### PyCharm ###
+# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
+# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
+
+# User-specific stuff
+.idea/**/workspace.xml
+.idea/**/tasks.xml
+.idea/**/usage.statistics.xml
+.idea/**/dictionaries
+.idea/**/shelf
+
+# Generated files
+.idea/**/contentModel.xml
+
+# Sensitive or high-churn files
+.idea/**/dataSources/
+.idea/**/dataSources.ids
+.idea/**/dataSources.local.xml
+.idea/**/sqlDataSources.xml
+.idea/**/dynamic.xml
+.idea/**/uiDesigner.xml
+.idea/**/dbnavigator.xml
+
+# Gradle
+.idea/**/gradle.xml
+.idea/**/libraries
+
+# Gradle and Maven with auto-import
+# When using Gradle or Maven with auto-import, you should exclude module files,
+# since they will be recreated, and may cause churn.  Uncomment if using
+# auto-import.
+# .idea/artifacts
+# .idea/compiler.xml
+# .idea/jarRepositories.xml
+# .idea/modules.xml
+# .idea/*.iml
+# .idea/modules
+# *.iml
+# *.ipr
+
+# CMake
+cmake-build-*/
+
+# Mongo Explorer plugin
+.idea/**/mongoSettings.xml
+
+# File-based project format
+*.iws
+
+# IntelliJ
+out/
+
+# mpeltonen/sbt-idea plugin
+.idea_modules/
+
+# JIRA plugin
+atlassian-ide-plugin.xml
+
+# Cursive Clojure plugin
+.idea/replstate.xml
+
+# Crashlytics plugin (for Android Studio and IntelliJ)
+com_crashlytics_export_strings.xml
+crashlytics.properties
+crashlytics-build.properties
+fabric.properties
+
+# Editor-based Rest Client
+.idea/httpRequests
+
+# Android studio 3.1+ serialized cache file
+.idea/caches/build_file_checksums.ser
+
+### PyCharm Patch ###
+# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721
+
+# *.iml
+# modules.xml
+# .idea/misc.xml
+# *.ipr
+
+# Sonarlint plugin
+# https://plugins.jetbrains.com/plugin/7973-sonarlint
+.idea/**/sonarlint/
+
+# SonarQube Plugin
+# https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin
+.idea/**/sonarIssues.xml
+
+# Markdown Navigator plugin
+# https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced
+.idea/**/markdown-navigator.xml
+.idea/**/markdown-navigator-enh.xml
+.idea/**/markdown-navigator/
+
+# Cache file creation bug
+# See https://youtrack.jetbrains.com/issue/JBR-2257
+.idea/$CACHE_FILE$
+
+# CodeStream plugin
+# https://plugins.jetbrains.com/plugin/12206-codestream
+.idea/codestream.xml
+
+### Python ###
+# Byte-compiled / optimized / DLL files
+
+# C extensions
+
+# Distribution / packaging
+
+# PyInstaller
+#  Usually these files are written by a python script from a template
+#  before PyInstaller builds the exe, so as to inject date/other infos into it.
+
+# Installer logs
+
+# Unit test / coverage reports
+
+# Translations
+
+# Django stuff:
+
+# Flask stuff:
+
+# Scrapy stuff:
+
+# Sphinx documentation
+
+# PyBuilder
+
+# Jupyter Notebook
+
+# IPython
+
+# pyenv
+
+# pipenv
+#   According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
+#   However, in case of collaboration, if having platform-specific dependencies or dependencies
+#   having no cross-platform support, pipenv may install dependencies that don't work, or not
+#   install all needed dependencies.
+
+# PEP 582; used by e.g. github.com/David-OConnor/pyflow
+
+# Celery stuff
+
+# SageMath parsed files
+
+# Environments
+
+# Spyder project settings
+
+# Rope project settings
+
+# mkdocs documentation
+
+# mypy
+
+# Pyre type checker
+
+# pytype static type analyzer
+
+# profiling data
+
+### SublimeText ###
+# Cache files for Sublime Text
+*.tmlanguage.cache
+*.tmPreferences.cache
+*.stTheme.cache
+
+# Workspace files are user-specific
+*.sublime-workspace
+
+# Project files should be checked into the repository, unless a significant
+# proportion of contributors will probably not be using Sublime Text
+# *.sublime-project
+
+# SFTP configuration file
+sftp-config.json
+
+# Package control specific files
+Package Control.last-run
+Package Control.ca-list
+Package Control.ca-bundle
+Package Control.system-ca-bundle
+Package Control.cache/
+Package Control.ca-certs/
+Package Control.merged-ca-bundle
+Package Control.user-ca-bundle
+oscrypto-ca-bundle.crt
+bh_unicode_properties.cache
+
+# Sublime-github package stores a github token in this file
+# https://packagecontrol.io/packages/sublime-github
+GitHub.sublime-settings
+
+# End of https://www.toptal.com/developers/gitignore/api/django,python,code,sublimetext,pycharm""",
+}
diff --git a/django_spinproject/modules/gitlab_ci.py b/django_spinproject/modules/gitlab_ci.py
new file mode 100644
index 0000000..84898c0
--- /dev/null
+++ b/django_spinproject/modules/gitlab_ci.py
@@ -0,0 +1,14 @@
+from ._base import BaseModule
+from .gitlab_ci_data import _V1_ENV, _V2_ENV
+from ..project_manager.project_info import ProjectInfo
+
+
+class GitlabCIModule(BaseModule):
+	name = 'gitlab-ci'
+	help_text = "Creates .gitlab-ci.yml file"
+	environments = (_V1_ENV, _V2_ENV)
+
+	@classmethod
+	def _upgrade_step(cls, current_version: int, project_info: ProjectInfo, **render_kwargs) -> None:
+		render_kwargs.update(project_info.config.docker.__dict__.copy())
+		super()._upgrade_step(current_version, project_info, **render_kwargs)
diff --git a/django_spinproject/modules/gitlab_ci_data/__init__.py b/django_spinproject/modules/gitlab_ci_data/__init__.py
new file mode 100644
index 0000000..325543e
--- /dev/null
+++ b/django_spinproject/modules/gitlab_ci_data/__init__.py
@@ -0,0 +1,9 @@
+from .v1 import _CONTENT as _V1_CONTENT
+from .v2 import _CONTENT as _V2_CONTENT
+from ...generic.extended_jinja_environment import ExtendedEnvironment
+
+from jinja2 import DictLoader
+
+
+_V1_ENV = ExtendedEnvironment(loader=DictLoader(_V1_CONTENT))
+_V2_ENV = ExtendedEnvironment(loader=DictLoader(_V2_CONTENT))
diff --git a/django_spinproject/modules/gitlab_ci_data/v1.py b/django_spinproject/modules/gitlab_ci_data/v1.py
new file mode 100644
index 0000000..b35533d
--- /dev/null
+++ b/django_spinproject/modules/gitlab_ci_data/v1.py
@@ -0,0 +1,91 @@
+_CONTENT = {
+	'.gitlab-ci.yml': '''stages:
+- check
+- deploy
+
+image: python:3.8
+
+services:
+  - postgres:13.1-alpine
+
+variables:
+  DJANGO_DATABASE_URL: pgsql://postgres:@postgres/postgres
+  DJANGO_SECRET_KEY: 'static'
+  DJANGO_ALLOWED_HOSTS: '127.0.0.1,localhost'
+  ### CI database configuration
+  POSTGRES_HOST_AUTH_METHOD: trust
+  #POSTGRES_DB: nice_marmot
+  #POSTGRES_USER: runner
+  POSTGRES_PASSWORD: ""
+  ### CI configuration
+  # Change pip's cache directory to be inside the project directory since we can
+  # only cache local items.
+  PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
+  # Faster CI caching
+  FF_USE_FASTZIP: 1
+
+cache:
+  paths:
+    - .cache/pip
+    - .cache/pypoetry
+    - venv/
+
+before_script:
+  - python -V  # Print out python version for debugging
+  - pip install -q poetry
+  - poetry config cache-dir "$CI_PROJECT_DIR/.cache/pypoetry"
+  - poetry config virtualenvs.create true
+
+test:
+  stage: check
+  script:
+    - script/setup
+    - script/cibuild
+  coverage: '/^TOTAL.+?(\d+\%)$/'
+  artifacts:
+    reports:
+      cobertura: coverage.xml
+
+deploy_bleeding:
+  when: manual
+  stage: deploy
+  image: "docker:19.03.1"
+  before_script:
+    - docker info
+  script:
+    - echo $DOCKER_PASSWORD | docker login --username {{ username }} --password-stdin {% if repository %}{{ repository }}{% endif %}
+    - docker build -t '{{ repository }}{% if repository %}/{% endif %}{{ image }}:bleeding' .
+    - docker push '{{ repository }}{% if repository %}/{% endif %}{{ image }}:bleeding'
+
+deploy_main:
+  when: manual
+  stage: deploy
+  image: "docker:19.03.1"
+  before_script:
+    - docker info
+  script:
+    - echo $DOCKER_PASSWORD | docker login --username {{ username }} --password-stdin {% if repository %}{{ repository }}{% endif %}
+    - docker build -t '{{ repository }}{% if repository %}/{% endif %}{{ image }}' .
+    - docker push '{{ repository }}{% if repository %}/{% endif %}{{ image }}'
+
+# ISSUE: nobody can guarantee that image did not change between deploy_bleeding and deploy_promote. Use at your own risk.
+# deploy_promote:
+#   when: manual
+#   stage: deploy
+#   script:
+#     - docker tag '{{ repository }}{% if repository %}/{% endif %}{{ image }}:bleeding' '{{ repository }}{% if repository %}/{% endif %}{{ image }}'
+#     - docker push '{{ repository }}{% if repository %}/{% endif %}{{ image }}'
+
+
+# pages:
+#   script:
+#     - pip install sphinx sphinx-rtd-theme
+#     - cd doc ; make html
+#     - mv build/html/ ../public/
+#   artifacts:
+#     paths:
+#       - public
+#   only:
+#     - master
+'''
+}
diff --git a/django_spinproject/modules/gitlab_ci_data/v2.py b/django_spinproject/modules/gitlab_ci_data/v2.py
new file mode 100644
index 0000000..f92c0d2
--- /dev/null
+++ b/django_spinproject/modules/gitlab_ci_data/v2.py
@@ -0,0 +1,91 @@
+_CONTENT = {
+	'.gitlab-ci.yml': '''stages:
+- check
+- deploy
+
+image: python:3.9.6-slim-bullseye
+
+services:
+  - postgres:13.1-alpine
+
+variables:
+  DJANGO_DATABASE_URL: pgsql://postgres:@postgres/postgres
+  DJANGO_SECRET_KEY: 'static'
+  DJANGO_ALLOWED_HOSTS: '127.0.0.1,localhost'
+  ### CI database configuration
+  POSTGRES_HOST_AUTH_METHOD: trust
+  #POSTGRES_DB: nice_marmot
+  #POSTGRES_USER: runner
+  POSTGRES_PASSWORD: ""
+  ### CI configuration
+  # Change pip's cache directory to be inside the project directory since we can
+  # only cache local items.
+  PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
+  # Faster CI caching
+  FF_USE_FASTZIP: 1
+
+cache:
+  paths:
+    - .cache/pip
+    - .cache/pypoetry
+    - venv/
+
+before_script:
+  - python -V  # Print out python version for debugging
+  - pip install -q poetry
+  - poetry config cache-dir "$CI_PROJECT_DIR/.cache/pypoetry"
+  - poetry config virtualenvs.create true
+
+test:
+  stage: check
+  script:
+    - script/setup
+    - script/cibuild
+  coverage: '/^TOTAL.+?(\d+\%)$/'
+  artifacts:
+    reports:
+      cobertura: coverage.xml
+
+deploy_bleeding:
+  when: manual
+  stage: deploy
+  image: "docker:19.03.1"
+  before_script:
+    - docker info
+  script:
+    - echo $DOCKER_PASSWORD | docker login --username {{ username }} --password-stdin {% if repository %}{{ repository }}{% endif %}
+    - docker build -t '{{ repository }}{% if repository %}/{% endif %}{{ image }}:bleeding' .
+    - docker push '{{ repository }}{% if repository %}/{% endif %}{{ image }}:bleeding'
+
+deploy_main:
+  when: manual
+  stage: deploy
+  image: "docker:19.03.1"
+  before_script:
+    - docker info
+  script:
+    - echo $DOCKER_PASSWORD | docker login --username {{ username }} --password-stdin {% if repository %}{{ repository }}{% endif %}
+    - docker build -t '{{ repository }}{% if repository %}/{% endif %}{{ image }}' .
+    - docker push '{{ repository }}{% if repository %}/{% endif %}{{ image }}'
+
+# ISSUE: nobody can guarantee that image did not change between deploy_bleeding and deploy_promote. Use at your own risk.
+# deploy_promote:
+#   when: manual
+#   stage: deploy
+#   script:
+#     - docker tag '{{ repository }}{% if repository %}/{% endif %}{{ image }}:bleeding' '{{ repository }}{% if repository %}/{% endif %}{{ image }}'
+#     - docker push '{{ repository }}{% if repository %}/{% endif %}{{ image }}'
+
+
+# pages:
+#   script:
+#     - pip install sphinx sphinx-rtd-theme
+#     - cd doc ; make html
+#     - mv build/html/ ../public/
+#   artifacts:
+#     paths:
+#       - public
+#   only:
+#     - master
+'''
+}
diff --git a/django_spinproject/modules/pg_readonly.py b/django_spinproject/modules/pg_readonly.py
new file mode 100644
index 0000000..dbda742
--- /dev/null
+++ b/django_spinproject/modules/pg_readonly.py
@@ -0,0 +1,16 @@
+from ._base import BaseModule
+from .pg_readonly_data import _V1_ENV
+from ..project_manager.project_info import ProjectInfo
+
+import os
+
+
+class PGReadonlyModule(BaseModule):
+	name = 'pg-readonly'
+	help_text = "Creates DatabaseWrapper class for readonly connection to Postgres"
+	environments = (_V1_ENV, )
+	files_dir = 'pg_readonly'
+
+	@classmethod
+	def get_full_files_dir(cls, project_info: ProjectInfo):
+		return os.path.join(project_info.config.main, cls.files_dir)
diff --git a/django_spinproject/modules/pg_readonly_data/__init__.py b/django_spinproject/modules/pg_readonly_data/__init__.py
new file mode 100644
index 0000000..5b3d1c1
--- /dev/null
+++ b/django_spinproject/modules/pg_readonly_data/__init__.py
@@ -0,0 +1,7 @@
+from .v1 import _CONTENT as _V1_CONTENT
+from ...generic.extended_jinja_environment import ExtendedEnvironment
+
+from jinja2 import DictLoader
+
+
+_V1_ENV = ExtendedEnvironment(loader=DictLoader(_V1_CONTENT))
diff --git a/django_spinproject/modules/pg_readonly_data/v1.py b/django_spinproject/modules/pg_readonly_data/v1.py
new file mode 100644
index 0000000..d2681dd
--- /dev/null
+++ b/django_spinproject/modules/pg_readonly_data/v1.py
@@ -0,0 +1,8 @@
+_CONTENT = {
+	'base.py': """from django.db.backends.postgresql import base
+class DatabaseWrapper(base.DatabaseWrapper):
+	def get_new_connection(self, conn_params):
+		conn = super(DatabaseWrapper, self).get_new_connection(conn_params)
+		conn.set_session(readonly=True)
+		return conn""",
+}
diff --git a/django_spinproject/modules/settings.py b/django_spinproject/modules/settings.py
new file mode 100644
index 0000000..dcf1ff7
--- /dev/null
+++ b/django_spinproject/modules/settings.py
@@ -0,0 +1,53 @@
+from ._base import BaseModule
+from .settings_data import _V1_ENV, _V2_ENV
+from ..generic.directory_cleaning import clean_dir
+from ..project_manager.project_info import ProjectInfo
+
+import os
+
+
+class SettingsModule(BaseModule):
+	name = 'settings'
+	help_text = """Improves settings.py file and creates .env.example file.
+Creates backup of settings.py file. If the backup exists, module does not update it.
+"""
+	environments = (_V1_ENV, _V2_ENV)
+	settings_filename = 'settings.py'
+	settings_backup_postfix = '.orig'
+
+	@classmethod
+	def _before_upgrade(cls, current_version: int, project_info: ProjectInfo) -> None:
+		full_files_dir = cls.get_full_files_dir(project_info)
+
+		if current_version == 0:
+			# creating backup of settings file
+			settings_orig_path = os.path.join(full_files_dir, cls.settings_filename)
+			settings_backup_path = settings_orig_path + cls.settings_backup_postfix
+
+			if os.path.exists(settings_orig_path) and not os.path.exists(settings_backup_path):
+				os.replace(settings_orig_path, settings_backup_path)
+
+	@classmethod
+	def _upgrade_step(cls, current_version: int, project_info: ProjectInfo, **render_kwargs) -> None:
+		render_kwargs['name'] = project_info.config.main
+		super()._upgrade_step(current_version, project_info, **render_kwargs)
+
+	@classmethod
+	def cleanup(cls, current_version: int, project_info: ProjectInfo) -> None:
+		if current_version > 0:
+			full_files_dir = cls.get_full_files_dir(project_info)
+			clean_dir(
+				cls.environments[current_version - 1].list_templates(),
+				cls.get_full_files_dir(project_info),
+				False,
+			)
+
+			# restoring settings backup
+			settings_backup_path = os.path.join(full_files_dir, cls.settings_filename + cls.settings_backup_postfix)
+
+			if os.path.exists(settings_backup_path):
+				os.replace(settings_backup_path, settings_backup_path.rstrip(cls.settings_backup_postfix))
+
+	@classmethod
+	def get_full_files_dir(cls, project_info: ProjectInfo):
+		return os.path.join(project_info.config.main, cls.files_dir)
diff --git a/django_spinproject/modules/settings_data/__init__.py b/django_spinproject/modules/settings_data/__init__.py
new file mode 100644
index 0000000..325543e
--- /dev/null
+++ b/django_spinproject/modules/settings_data/__init__.py
@@ -0,0 +1,9 @@
+from .v1 import _CONTENT as _V1_CONTENT
+from .v2 import _CONTENT as _V2_CONTENT
+from ...generic.extended_jinja_environment import ExtendedEnvironment
+
+from jinja2 import DictLoader
+
+
+_V1_ENV = ExtendedEnvironment(loader=DictLoader(_V1_CONTENT))
+_V2_ENV = ExtendedEnvironment(loader=DictLoader(_V2_CONTENT))
diff --git a/django_spinproject/modules/settings_data/v1.py b/django_spinproject/modules/settings_data/v1.py
new file mode 100644
index 0000000..53caaf0
--- /dev/null
+++ b/django_spinproject/modules/settings_data/v1.py
@@ -0,0 +1,192 @@
+_CONTENT = {
+	'settings.py': """
+# Generated by 'django-spinproject', based on Django 2.1.2.
+#
+# For more information on this file, see
+# https://docs.djangoproject.com/en/2.1/topics/settings/
+#
+# For the full list of settings and their values, see
+# https://docs.djangoproject.com/en/2.1/ref/settings/
+#
+# Production use checklist:
+# https://docs.djangoproject.com/en/2.1/howto/deployment/checklist/
+
+import environ
+env = environ.Env(
+	DJANGO_DEBUG=(bool, False),  # casting, default value
+	DJANGO_DEBUG_SQL=(bool, False),
+)
+environ.Env.read_env()
+
+def passthrough(x):
+	# print('passthrough', repr(x))
+	return x
+
+import os
+
+SECRET_KEY = passthrough(env('DJANGO_SECRET_KEY'))
+DEBUG = passthrough(env('DJANGO_DEBUG'))
+ALLOWED_HOSTS = passthrough(env.list('DJANGO_ALLOWED_HOSTS'))
+
+# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
+BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+
+# Database
+# https://docs.djangoproject.com/en/1.11/ref/settings/#databases
+
+# def readonly(x):
+# 	x['ENGINE'] = '{{ name }}.pg_readonly'
+# 	return x
+
+DATABASES = {
+	'default': passthrough(env.db('DJANGO_DATABASE_URL')),
+	# 'additional': passthrough(readonly(env.db('DJANGO_DATABASE_ADDITIONAL_URL'))),
+}
+
+
+# Application definition
+
+INSTALLED_APPS = [
+	'whitenoise.runserver_nostatic',
+
+	# place your apps here
+
+	'django.contrib.admin',
+	'django.contrib.auth',
+	'django.contrib.contenttypes',
+	'django.contrib.sessions',
+	'django.contrib.messages',
+	'django.contrib.staticfiles',
+]
+
+MIDDLEWARE = [
+	'django.middleware.security.SecurityMiddleware',
+
+	'whitenoise.middleware.WhiteNoiseMiddleware',
+
+	'django.contrib.sessions.middleware.SessionMiddleware',
+	'django.middleware.common.CommonMiddleware',
+	'django.middleware.csrf.CsrfViewMiddleware',
+	'django.contrib.auth.middleware.AuthenticationMiddleware',
+	'django.contrib.messages.middleware.MessageMiddleware',
+	'django.middleware.clickjacking.XFrameOptionsMiddleware',
+]
+
+ROOT_URLCONF = '{{ name }}.urls'
+
+TEMPLATES = [
+	{
+		'BACKEND': 'django.template.backends.django.DjangoTemplates',
+		'DIRS': [],
+		'APP_DIRS': True,
+		'OPTIONS': {
+			'context_processors': [
+				'django.template.context_processors.debug',
+				'django.template.context_processors.request',
+				'django.contrib.auth.context_processors.auth',
+				'django.contrib.messages.context_processors.messages',
+			],
+		},
+	},
+]
+
+WSGI_APPLICATION = '{{ name }}.wsgi.application'
+# ASGI_APPLICATION = '{{ name }}.asgi.application'  # uncomment if needed
+
+
+# Password validation
+# https://docs.djangoproject.com/en/2.1/ref/settings/#auth-password-validators
+
+AUTH_PASSWORD_VALIDATORS = [
+	{ 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator' },
+	{ 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator' },
+	{ 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator' },
+	{ 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator' },
+]
+
+
+# Internationalization
+# https://docs.djangoproject.com/en/2.1/topics/i18n/
+
+LANGUAGE_CODE = 'en-us'
+
+TIME_ZONE = 'Europe/Moscow'
+
+USE_I18N = True
+
+USE_L10N = True
+
+USE_TZ = True
+
+
+# Logging
+
+LOGGING = {
+	'version': 1,
+	'disable_existing_loggers': False,
+	'formatters': {
+		'stamp': {
+			'format': '%(asctime)s %(name)s %(module)s [%(levelname)s] %(message)s'
+		},
+	},
+	'handlers': {
+		'console': {
+			'class': 'logging.StreamHandler',
+			'formatter': 'stamp',
+		},
+	},
+	'loggers': {
+		# 'django': {
+		# 	'handlers': ['console'],
+		# 	'level': 'DEBUG',
+		# },
+		'{{ name }}': {
+			'handlers': ['console'],
+			'level': 'DEBUG',
+		},
+	},
+}
+if env('DJANGO_DEBUG_SQL'):
+	LOGGING['loggers']['django.db'] = {
+		'handlers': ['console'],
+		'level': 'DEBUG',
+	}
+
+
+## No-CSRF auth is required for xauth
+
+# from rest_framework.authentication import SessionAuthentication
+# class CsrfExemptSessionAuthentication(SessionAuthentication):
+# 	def enforce_csrf(self, request):
+# 		# Ignore CSRF checks (that's a security hole, make sure you know what you're doing)
+# 		return
+#
+# REST_FRAMEWORK = {
+# 	'DEFAULT_PERMISSION_CLASSES': [
+# 		'rest_framework.permissions.IsAuthenticated',
+# 	],
+# 	'DEFAULT_AUTHENTICATION_CLASSES': [
+# 		'{{ name }}.settings.CsrfExemptSessionAuthentication',
+# 		# 'rest_framework.authentication.SessionAuthentication',
+# 		'rest_framework.authentication.BasicAuthentication',
+# 	],
+# }
+
+
+# Static files (CSS, JavaScript, Images)
+# https://docs.djangoproject.com/en/2.1/howto/static-files/
+
+STATIC_URL = '/static/'
+
+## Whitenoise
+
+STATIC_ROOT = os.path.join(BASE_DIR, 'static')
+STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage'
+# STATICFILES_STORAGE = 'whitenoise.storage.CompressedStaticFilesStorage'
+""",
+		'.env.example': """DJANGO_SECRET_KEY=""
+DJANGO_DEBUG=True
+DJANGO_DEBUG_SQL=False
+DJANGO_ALLOWED_HOSTS=""
+DJANGO_DATABASE_URL="sqlite:///db.sqlite3\"""",
+}
diff --git a/django_spinproject/modules/settings_data/v2.py b/django_spinproject/modules/settings_data/v2.py
new file mode 100644
index 0000000..50d7a98
--- /dev/null
+++ b/django_spinproject/modules/settings_data/v2.py
@@ -0,0 +1,197 @@
+_CONTENT = {
+	'settings.py': """
+# Generated by 'django-spinproject', based on Django 2.1.2.
+#
+# For more information on this file, see
+# https://docs.djangoproject.com/en/2.1/topics/settings/
+#
+# For the full list of settings and their values, see
+# https://docs.djangoproject.com/en/2.1/ref/settings/
+#
+# Production use checklist:
+# https://docs.djangoproject.com/en/2.1/howto/deployment/checklist/
+
+import environ
+env = environ.Env(
+	DJANGO_DEBUG=(bool, False),  # casting, default value
+	DJANGO_DEBUG_SQL=(bool, False),
+)
+environ.Env.read_env()
+
+def passthrough(x):
+	# print('passthrough', repr(x))
+	return x
+
+import os
+
+SECRET_KEY = passthrough(env('DJANGO_SECRET_KEY'))
+DEBUG = passthrough(env('DJANGO_DEBUG'))
+ALLOWED_HOSTS = passthrough(env.list('DJANGO_ALLOWED_HOSTS'))
+
+# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
+BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+
+# Database
+# https://docs.djangoproject.com/en/1.11/ref/settings/#databases
+
+# def readonly(x):
+# 	x['ENGINE'] = '{{ name }}.pg_readonly'
+# 	return x
+
+DATABASES = {
+	'default': passthrough(env.db('DJANGO_DATABASE_URL')),
+	# 'additional': passthrough(readonly(env.db('DJANGO_DATABASE_ADDITIONAL_URL'))),
+}
+
+
+# Application definition
+
+INSTALLED_APPS = [
+	'whitenoise.runserver_nostatic',
+
+	# place your apps here
+
+	'django.contrib.admin',
+	'django.contrib.auth',
+	'django.contrib.contenttypes',
+	'django.contrib.sessions',
+	'django.contrib.messages',
+	'django.contrib.staticfiles',
+]
+
+MIDDLEWARE = [
+	'django.middleware.security.SecurityMiddleware',
+
+	'whitenoise.middleware.WhiteNoiseMiddleware',
+
+	'django.contrib.sessions.middleware.SessionMiddleware',
+	'django.middleware.common.CommonMiddleware',
+	'django.middleware.csrf.CsrfViewMiddleware',
+	'django.contrib.auth.middleware.AuthenticationMiddleware',
+	'django.contrib.messages.middleware.MessageMiddleware',
+	'django.middleware.clickjacking.XFrameOptionsMiddleware',
+]
+
+ROOT_URLCONF = '{{ name }}.urls'
+
+TEMPLATES = [
+	{
+		'BACKEND': 'django.template.backends.django.DjangoTemplates',
+		'DIRS': [],
+		'APP_DIRS': True,
+		'OPTIONS': {
+			'context_processors': [
+				'django.template.context_processors.debug',
+				'django.template.context_processors.request',
+				'django.contrib.auth.context_processors.auth',
+				'django.contrib.messages.context_processors.messages',
+			],
+		},
+	},
+]
+
+WSGI_APPLICATION = '{{ name }}.wsgi.application'
+# ASGI_APPLICATION = '{{ name }}.asgi.application'  # uncomment if needed
+
+
+# Password validation
+# https://docs.djangoproject.com/en/2.1/ref/settings/#auth-password-validators
+
+AUTH_PASSWORD_VALIDATORS = [
+	{ 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator' },
+	{ 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator' },
+	{ 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator' },
+	{ 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator' },
+]
+
+
+# Internationalization
+# https://docs.djangoproject.com/en/2.1/topics/i18n/
+
+LANGUAGE_CODE = 'en-us'
+
+TIME_ZONE = 'Europe/Moscow'
+
+USE_I18N = True
+
+USE_L10N = True
+
+USE_TZ = True
+
+
+# Logging
+
+LOGGING = {
+	'version': 1,
+	'disable_existing_loggers': False,
+	'formatters': {
+		'stamp': {
+			'format': '%(asctime)s %(name)s %(module)s [%(levelname)s] %(message)s'
+		},
+	},
+	'handlers': {
+		'console': {
+			'class': 'logging.StreamHandler',
+			'formatter': 'stamp',
+		},
+	},
+	'loggers': {
+		# 'django': {
+		# 	'handlers': ['console'],
+		# 	'level': 'DEBUG',
+		# },
+		'{{ name }}': {
+			'handlers': ['console'],
+			'level': 'DEBUG',
+		},
+	},
+}
+if env('DJANGO_DEBUG_SQL'):
+	LOGGING['loggers']['django.db'] = {
+		'handlers': ['console'],
+		'level': 'DEBUG',
+	}
+
+
+## No-CSRF auth is required for xauth
+
+# from rest_framework.authentication import SessionAuthentication
+# class CsrfExemptSessionAuthentication(SessionAuthentication):
+# 	def enforce_csrf(self, request):
+# 		# Ignore CSRF checks (that's a security hole, make sure you know what you're doing)
+# 		return
+#
+# REST_FRAMEWORK = {
+# 	'DEFAULT_PERMISSION_CLASSES': [
+# 		'rest_framework.permissions.IsAuthenticated',
+# 	],
+# 	'DEFAULT_AUTHENTICATION_CLASSES': [
+# 		'{{ name }}.settings.CsrfExemptSessionAuthentication',
+# 		# 'rest_framework.authentication.SessionAuthentication',
+# 		'rest_framework.authentication.BasicAuthentication',
+# 	],
+# }
+
+
+# Static files (CSS, JavaScript, Images)
+# https://docs.djangoproject.com/en/2.1/howto/static-files/
+
+STATIC_URL = '/static/'
+
+# Default primary key field type
+# https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field
+
+DEFAULT_AUTO_FIELD = 'django.db.models.AutoField'
+
+## Whitenoise
+
+STATIC_ROOT = os.path.join(BASE_DIR, 'static')
+STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage'
+# STATICFILES_STORAGE = 'whitenoise.storage.CompressedStaticFilesStorage'
+""",
+		'.env.example': """DJANGO_SECRET_KEY=""
+DJANGO_DEBUG=True
+DJANGO_DEBUG_SQL=False
+DJANGO_ALLOWED_HOSTS=""
+DJANGO_DATABASE_URL="sqlite:///db.sqlite3\"""",
+}
diff --git a/django_spinproject/modules/srta.py b/django_spinproject/modules/srta.py
new file mode 100644
index 0000000..64f13d4
--- /dev/null
+++ b/django_spinproject/modules/srta.py
@@ -0,0 +1,47 @@
+from ._base import BaseModule
+from ._mixins import ChmodMixin
+from .srta_data import (
+	_V1_ENV, _V1_SYMLINKS,
+	_V2_ENV, _V2_SYMLINKS,
+)
+from ..generic.directory_cleaning import clean_dir
+from ..project_manager.project_info import ProjectInfo
+
+import os
+import shlex
+import subprocess
+from typing import Optional
+
+
+class SRTAModule(BaseModule, ChmodMixin):
+	name = 'srta'
+	help_text = "Creates srta scripts and additional symlinks"
+	environments = (_V1_ENV, _V2_ENV)
+	symlinks = (_V1_SYMLINKS, _V2_SYMLINKS)
+	files_dir = 'script'
+
+	@classmethod
+	def _after_upgrade(cls, current_version: int, project_info: ProjectInfo) -> None:
+		cls.change_files_mode(current_version)
+		full_path_to_dir = os.path.join(os.getcwd(), cls.get_full_files_dir(project_info))
+
+		for link_name, scripts_name in cls.symlinks[current_version].items():
+			link_path = os.path.join(full_path_to_dir, link_name)
+
+			if not os.path.exists(link_path):
+				print(f"Symlink: {link_name} -> {scripts_name}")
+				subprocess.run(f"cd {shlex.quote(full_path_to_dir)}; ln -s {scripts_name} {link_name}", shell=True)
+			else:
+				print(f"Symlink: {link_name} -> {scripts_name} already exists")
+
+	@classmethod
+	def _upgrade_step(cls, current_version: int, project_info: ProjectInfo, **render_kwargs) -> None:
+		render_kwargs['name'] = project_info.config.main
+		super()._upgrade_step(current_version, project_info, **render_kwargs)
+
+	@classmethod
+	def cleanup(cls, current_version: int, project_info: Optional[ProjectInfo] = None) -> None:
+		if current_version > 0:
+			files = cls.environments[current_version - 1].list_templates()
+			symlinks = list(cls.symlinks[current_version - 1].keys())
+			clean_dir(files + symlinks, cls.get_full_files_dir(project_info))
diff --git a/django_spinproject/modules/srta_data/__init__.py b/django_spinproject/modules/srta_data/__init__.py
new file mode 100644
index 0000000..b22e5dd
--- /dev/null
+++ b/django_spinproject/modules/srta_data/__init__.py
@@ -0,0 +1,9 @@
+from .v1 import _CONTENT as _V1_CONTENT, _SYMLINKS as _V1_SYMLINKS
+from .v2 import _CONTENT as _V2_CONTENT, _SYMLINKS as _V2_SYMLINKS
+from ...generic.extended_jinja_environment import ExtendedEnvironment
+
+from jinja2 import DictLoader
+
+
+_V1_ENV = ExtendedEnvironment(loader=DictLoader(_V1_CONTENT))
+_V2_ENV = ExtendedEnvironment(loader=DictLoader(_V2_CONTENT))
diff --git a/django_spinproject/modules/srta_data/v1.py b/django_spinproject/modules/srta_data/v1.py
new file mode 100644
index 0000000..a9498d7
--- /dev/null
+++ b/django_spinproject/modules/srta_data/v1.py
@@ -0,0 +1,106 @@
+_CONTENT = {
+	'bootstrap': '''#!/bin/bash
+set -e
+set -x
+
+which python3 || which python
+#which virtualenv
+which poetry
+
+poetry install''',
+
+	'console': '''#!/bin/bash
+set -e
+set -x
+
+poetry run python manage.py shell''',
+
+	'server': '''#!/bin/bash
+set -e
+set -x
+
+poetry run python manage.py runserver 0.0.0.0:8000''',
+
+	'setup': '''#!/bin/bash
+set -e
+set -x
+
+script/bootstrap
+
+# Create .env if not present
+MAIN_FOLDER={{ name }}
+if [ ! -e $MAIN_FOLDER/.env ]; then
+	# Copy template
+	cp $MAIN_FOLDER/.env.example $MAIN_FOLDER/.env
+	# Create secret key (TODO: find a crypto-safer way like /dev/random)
+	SECRET=$(mcookie)
+	sed -i 's|DJANGO_SECRET_KEY=""|DJANGO_SECRET_KEY="'$SECRET'"|g' $MAIN_FOLDER/.env
+	# Show
+	cat $MAIN_FOLDER/.env
+	echo ".env has been autogenerated, to edit: $ nano $MAIN_FOLDER/.env"
+fi
+# Clear SQLite database if present
+if [ -e db.sqlite3 ]; then
+        mv db.sqlite3 db.sqlite3~
+fi
+# Run migrations
+poetry run python manage.py migrate''',
+
+	'test': '''#!/bin/bash
+set -e
+set -x
+
+
+lint() {
+	poetry run flake8 --select=C,F,E101,E112,E502,E72,E73,E74,E9,W291,W6 --exclude=.cache,migrations
+}
+
+
+case $# in 
+	0)
+		lint
+		poetry run pytest
+		poetry run ./manage.py makemigrations --check --dry-run
+		;;
+	1)
+		case $1 in 
+			'--lint')
+				lint
+				;;
+			*)
+				printf 'Incorrect arg: %s\n' $1 >&2
+				exit 2
+				;;
+		esac
+		;;
+	*)
+		echo 'Incorrect count of args' >&2
+		exit 2
+		;;
+esac''',
+
+	'update': '''#!/bin/bash
+set -e
+set -x
+
+script/bootstrap
+poetry run python manage.py migrate''',
+
+	'cibuild': '''#!/bin/bash
+set -e
+set -x
+
+export CI=true
+script/test''',
+
+	'x-clean': '''#!/bin/bash
+set -e
+set -x
+
+find -depth -name '__pycache__' -exec rm -rfv '{}' ';'
+'''
+}
+_SYMLINKS = {
+	'shell': 'console',
+	'run': 'server',
+}
diff --git a/django_spinproject/modules/srta_data/v2.py b/django_spinproject/modules/srta_data/v2.py
new file mode 100644
index 0000000..1cbb3ae
--- /dev/null
+++ b/django_spinproject/modules/srta_data/v2.py
@@ -0,0 +1,106 @@
+_CONTENT = {
+	'bootstrap': '''#!/bin/bash
+set -e
+set -x
+
+which python3 || which python
+#which virtualenv
+which poetry
+
+poetry install --sync''',
+
+	'console': '''#!/bin/bash
+set -e
+set -x
+
+poetry run python manage.py shell''',
+
+	'server': '''#!/bin/bash
+set -e
+set -x
+
+poetry run python manage.py runserver 0.0.0.0:8000''',
+
+	'setup': '''#!/bin/bash
+set -e
+set -x
+
+script/bootstrap
+
+# Create .env if not present
+MAIN_FOLDER={{ name }}
+if [ ! -e $MAIN_FOLDER/.env ]; then
+	# Copy template
+	cp $MAIN_FOLDER/.env.example $MAIN_FOLDER/.env
+	# Create secret key (TODO: find a crypto-safer way like /dev/random)
+	SECRET=$(mcookie)
+	sed -i 's|DJANGO_SECRET_KEY=""|DJANGO_SECRET_KEY="'$SECRET'"|g' $MAIN_FOLDER/.env
+	# Show
+	cat $MAIN_FOLDER/.env
+	echo ".env has been autogenerated, to edit: $ nano $MAIN_FOLDER/.env"
+fi
+# Clear SQLite database if present
+if [ -e db.sqlite3 ]; then
+        mv db.sqlite3 db.sqlite3~
+fi
+# Run migrations
+poetry run python manage.py migrate''',
+
+	'test': '''#!/bin/bash
+set -e
+set -x
+
+
+lint() {
+	poetry run flake8 --select=C,F,E101,E112,E502,E72,E73,E74,E9,W291,W6 --exclude=.cache,migrations
+}
+
+
+case $# in 
+	0)
+		lint
+		poetry run pytest
+		poetry run ./manage.py makemigrations --check --dry-run
+		;;
+	1)
+		case $1 in 
+			'--lint')
+				lint
+				;;
+			*)
+				printf 'Incorrect arg: %s\n' $1 >&2
+				exit 2
+				;;
+		esac
+		;;
+	*)
+		echo 'Incorrect count of args' >&2
+		exit 2
+		;;
+esac''',
+
+	'update': '''#!/bin/bash
+set -e
+set -x
+
+script/bootstrap
+poetry run python manage.py migrate''',
+
+	'cibuild': '''#!/bin/bash
+set -e
+set -x
+
+export CI=true
+script/test''',
+
+	'x-clean': '''#!/bin/bash
+set -e
+set -x
+
+find -depth -name '__pycache__' -exec rm -rfv '{}' ';'
+'''
+}
+_SYMLINKS = {
+	'shell': 'console',
+	'run': 'server',
+}
diff --git a/django_spinproject/project_manager/__init__.py b/django_spinproject/project_manager/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/django_spinproject/project_manager/project_info.py b/django_spinproject/project_manager/project_info.py
new file mode 100644
index 0000000..bcbe826
--- /dev/null
+++ b/django_spinproject/project_manager/project_info.py
@@ -0,0 +1,142 @@
+from ..generic.exit import exit_with_output
+from ..generic.serializable_obj import SerializationTrait
+from ..generic.compatibility_trait import CompatibilityTrait
+from ..constants import DEFAULT_MAIN
+
+import json
+import os
+from typing import Union, Optional
+
+
+class ProjectInfoError(Exception):
+	"""
+	Basic class of errors when working with project information.
+	"""
+	...
+
+
+class DockerConfig(SerializationTrait, CompatibilityTrait):
+	class_obj_attrs = 'repository', 'image', 'tag', 'username'
+
+	def __init__(
+		self,
+		repository: str = '',		# docker.mycompany.local:5000
+		image: str = 'myimage',		# mycompany/myimage
+		tag: str = 'latest',
+		username: str = 'user',
+	):
+		self.repository = repository
+		self.image = image
+		self.tag = tag
+		self.username = username
+
+
+class ProjectConfig(SerializationTrait, CompatibilityTrait):
+	"""
+	Stores the part about the project config.
+	"""
+	class_obj_attrs = 'project_name', 'main', 'module', 'docker'
+
+	def __init__(
+		self,
+		project_name: str,
+		main: str = DEFAULT_MAIN,
+		module: Optional[dict] = None,
+		docker: DockerConfig = DockerConfig(),
+	):
+		self.project_name = project_name
+		self.main = main
+		self.module = module if module is not None else {}
+		self.docker = docker
+
+	def serialize(self) -> dict:
+		serialized_obj = super(ProjectConfig, self).serialize()
+
+		for module_name in serialized_obj['module']:
+			module_config = serialized_obj['module'][module_name]
+
+			if isinstance(module_config, SerializationTrait):
+				serialized_obj['module'][module_name] = module_config.serialize()
+
+		return serialized_obj
+
+
+class ProjectInfo(SerializationTrait):
+	"""
+	Stores all information about the project.
+
+	Allows to dump data to disk and load from it.
+	"""
+	FILENAME = 'spinproject.json'		# Changing the filename in production will lead to errors
+	CONFIG_LABEL = 'config'
+	DOCKER_LABEL = 'docker'
+
+	def __init__(self, project_name: str, main: str = DEFAULT_MAIN):
+		self.config = ProjectConfig(project_name, main)
+		self.modules = []
+		self.config_version = 1
+		self.migration_state = {}
+
+	@classmethod
+	def load(cls) -> 'ProjectInfo':
+		"""
+		Loads project info data from disk.
+		"""
+		try:
+			with open(cls.FILENAME, mode='r') as file:
+				new_instance = cls('')
+				new_instance_data = json.load(file, object_hook=cls.project_objects_hook)
+
+				for key in new_instance.__dict__.keys():
+					if key in new_instance_data:
+						new_instance.__setattr__(key, new_instance_data[key])
+
+				try:
+					new_instance.self_check()
+
+				except ProjectInfoError as e:
+					exit_with_output(str(e), 1)
+
+				return new_instance
+
+		except FileNotFoundError:
+			exit_with_output(f"Unable to read the project info file. File {cls.FILENAME} doesn't exists", 1)
+
+	def save(self, overwrite: bool = True) -> None:
+		"""
+		Saves an instance of the class to a project info file.
+
+		Args:
+			overwrite:
+				If True (by default) - The file will be overwritten.
+				If False - Checks the existence of project file before writing. If there is a file, the program will
+					fail with an error.
+		"""
+		if not overwrite and self.check_project_file_existence():
+			exit_with_output(f"{self.FILENAME} file already exists", 1)
+
+		try:
+			with open(self.FILENAME, mode='w') as file:
+				json.dump(self.serialize(), file, indent=2)
+
+		except PermissionError:
+			exit_with_output("Unable to save project info. Permission denied", 1)
+
+	@classmethod
+	def project_objects_hook(cls, jdict: dict) -> Union[dict, ProjectConfig]:
+		configs = [(cls.CONFIG_LABEL, ProjectConfig), (cls.DOCKER_LABEL, DockerConfig)]
+
+		for label, ConfigClass in configs:
+			if label in jdict:
+				if ConfigClass.is_compatible(jdict[label]):
+					jdict[label] = ConfigClass(**jdict[label])
+
+		return jdict
+
+	@classmethod
+	def check_project_file_existence(cls):
+		return cls.FILENAME in os.listdir()
+
+	def self_check(self) -> None:
+		if sorted(self.modules) != sorted(self.migration_state.keys()):
+			raise ProjectInfoError("Modules are inconsistent with migrations")
diff --git a/django_spinproject/project_manager/project_info_manager.py b/django_spinproject/project_manager/project_info_manager.py
new file mode 100644
index 0000000..1b963c8
--- /dev/null
+++ b/django_spinproject/project_manager/project_info_manager.py
@@ -0,0 +1,180 @@
+from .project_info import ProjectInfo
+from ..modules import MODULES
+from ..generic.exit import exit_with_output
+
+import os
+
+
+def _check_module_existence(module_name: str) -> None:
+	if module_name not in MODULES:
+		exit_with_output(f"Module {module_name} doesn't exists", 1)
+
+
+def check_module_existence(method):
+	"""
+	Decorator for checking the existence of a module.
+	"""
+	def inner(cls, module_name: str, *args, **kwargs):
+		_check_module_existence(module_name)
+		return method(cls, module_name, *args, **kwargs)
+
+	return inner
+
+
+def check_project_info_file_existence(method):
+	"""
+	Decorator for checking the existence of a project information file.
+	"""
+	def inner(cls, *args, **kwargs):
+		if not ProjectInfo.check_project_file_existence():
+			exit_with_output("Project is not initialized", 1)
+
+		return method(cls, *args, **kwargs)
+
+	return inner
+
+
+def check_django_project_existence(method):
+	"""
+	Decorator of checking the existence of the Django project.
+	"""
+	def inner(cls, *args, **kwargs):
+		if 'manage.py' not in os.listdir():
+			exit_with_output(
+				"Project not found\nMake sure that the command is called at the root of the django project",
+				1,
+			)
+
+		return method(cls, *args, **kwargs)
+
+	return inner
+
+
+def ask_project_name() -> str:
+	"""
+	Polls the user until he specifies the name of the project.
+	"""
+	project_name = ""
+
+	while not project_name:
+		project_name = input("Enter project name (not used yet): ")
+
+		if not project_name:
+			print("Project name can't be empty")
+
+	return project_name
+
+
+class ProjectInfoManager:
+	@classmethod
+	@check_django_project_existence
+	def init(cls) -> None:
+		"""
+		Initializes the project file.
+		"""
+		project_info = ProjectInfo(project_name=ask_project_name())
+		project_info.save(overwrite=False)
+		print("""Note: third-party packages are required for some modules.
+These commands should do the trick:
+
+ poetry init
+ # For Django itself
+ poetry add django
+ # For its PostgreSQL adapter
+ poetry add psycopg2-binary
+ # For settings module
+ poetry add django-environ whitenoise
+ # For linter script
+ poetry add --dev flake8
+ # For pytest
+ poetry add --dev pytest pytest-django pytest-cov
+
+If you don't use poetry, other package manager will do, too.
+""")
+
+	@classmethod
+	@check_django_project_existence
+	@check_project_info_file_existence
+	def enable_modules(cls, *modules_to_enable) -> None:
+		project_info = ProjectInfo.load()
+
+		for module_name in modules_to_enable:
+			_check_module_existence(module_name)
+
+			if module_name not in project_info.modules:
+				project_info.modules.append(module_name)
+				project_info.migration_state[module_name] = 0
+
+		project_info.save()
+
+		print(
+			ENABLE_MODULE_MESSAGE_TEMPLATE.format(
+				modules_as_list=', '.join(modules_to_enable),
+				modules_as_args=' '.join(modules_to_enable),
+			),
+		)
+
+	@classmethod
+	@check_django_project_existence
+	@check_project_info_file_existence
+	@check_module_existence
+	def disable_module(cls, module_name: str) -> None:
+		project_info = ProjectInfo.load()
+
+		if module_name in project_info.modules:
+			MODULES[module_name].cleanup(project_info.migration_state[module_name], project_info)
+			project_info.modules.remove(module_name)
+			del project_info.migration_state[module_name]
+			project_info.save()
+			print(f"Successfully disabled module: {module_name}")
+		else:
+			exit_with_output(f"Module {module_name} already disabled", 1)
+
+	@classmethod
+	@check_django_project_existence
+	@check_project_info_file_existence
+	def upgrade_modules(cls, *modules_to_upgrade) -> None:
+		project_info = ProjectInfo.load()
+
+		if not modules_to_upgrade:
+			modules_to_upgrade = project_info.modules
+
+		else:
+			modules_to_upgrade = set(modules_to_upgrade)
+			module_names_intersection = set(project_info.modules) & modules_to_upgrade
+			not_enabled_modules = modules_to_upgrade - set(module_names_intersection)
+
+			if not_enabled_modules:
+				exit_with_output(
+					"Some modules are not enabled or don't exist and therefore cannot be updated\n"
+					f"Modules: {','.join(not_enabled_modules)}",
+					1,
+				)
+
+			modules_to_upgrade = module_names_intersection
+
+		for module_name in modules_to_upgrade:
+			_module_version_before_update = project_info.migration_state[module_name]
+			current_module_version = _module_version_before_update
+			module = MODULES[module_name]
+			module_last_version = module.last_version()
+
+			while current_module_version < module_last_version:
+				try:
+					module.upgrade_step(current_module_version, project_info)
+					current_module_version += 1
+					project_info.migration_state[module_name] = current_module_version
+					project_info.save()
+
+				except ValueError as e:
+					exit_with_output(f"Failed to update the module {module_name}. {e}", 1)
+
+			if _module_version_before_update != module_last_version:
+				print(f"Successfully upgraded {module_name}: {_module_version_before_update} -> {module_last_version}")
+
+
+ENABLE_MODULE_MESSAGE_TEMPLATE = """Successfully enabled modules: {modules_as_list}
+
+The modules was added to config, but migrations
+were not run. Run them with:
+    django-spinproject --upgrade {modules_as_args}"""
diff --git a/poetry.lock b/poetry.lock
new file mode 100644
index 0000000..bf96c74
--- /dev/null
+++ b/poetry.lock
@@ -0,0 +1,30 @@
+[[package]]
+name = "jinja2"
+version = "3.1.2"
+description = "A very fast and expressive template engine."
+category = "main"
+optional = false
+python-versions = ">=3.7"
+
+[package.dependencies]
+MarkupSafe = ">=2.0"
+
+[package.extras]
+i18n = ["Babel (>=2.7)"]
+
+[[package]]
+name = "markupsafe"
+version = "2.1.1"
+description = "Safely add untrusted strings to HTML/XML markup."
+category = "main"
+optional = false
+python-versions = ">=3.7"
+
+[metadata]
+lock-version = "1.1"
+python-versions = "^3.7"
+content-hash = "fdf86553de6f950425c8ca77fe37127c9242c83445ce44b951ee4032ef72ea2f"
+
+[metadata.files]
+jinja2 = []
+markupsafe = []
diff --git a/pyproject.toml b/pyproject.toml
index 30ee9c9..41f9a9a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
 [tool.poetry]
 name = "django-spinproject"
-version = "1.5.0"
+version = "2.1.0-rc6"
 description = "Opinionated version of `startproject` with some popular third-party packages. Starter pack includes: whitenoise, django-environ, logging, GitHub Scripts to Rule Them All, basic Dockerfile and Makefile."
 authors = ["m1kc (Max Musatov) <m1kc@yandex.ru>"]
 license = "MIT"
@@ -17,7 +17,8 @@ classifiers = [
 django-spinproject = 'django_spinproject.bin.spinproject:main'
 
 [tool.poetry.dependencies]
-python = "^3.6"
+python = "^3.7"
+Jinja2 = "^3.1.2"
 
 [tool.poetry.dev-dependencies]
 


Test patch:
diff --git a/django_spinproject/modules/pytest.py b/django_spinproject/modules/pytest.py
new file mode 100644
index 0000000..306f951
--- /dev/null
+++ b/django_spinproject/modules/pytest.py
@@ -0,0 +1,16 @@
+from ._base import BaseModule
+from .pytest_data import _V1_ENV
+from ..project_manager.project_info import ProjectInfo
+
+from typing import Optional
+
+
+class PytestModule(BaseModule):
+	name = 'pytest'
+	help_text = "Creates pytest.ini and .coveragerc files"
+	environments = (_V1_ENV, )
+
+	@classmethod
+	def _upgrade_step(cls, current_version: int, project_info: ProjectInfo, **render_kwargs) -> None:
+		render_kwargs['name'] = project_info.config.main
+		super()._upgrade_step(current_version, project_info, **render_kwargs)
diff --git a/django_spinproject/modules/pytest_data/__init__.py b/django_spinproject/modules/pytest_data/__init__.py
new file mode 100644
index 0000000..5b3d1c1
--- /dev/null
+++ b/django_spinproject/modules/pytest_data/__init__.py
@@ -0,0 +1,7 @@
+from .v1 import _CONTENT as _V1_CONTENT
+from ...generic.extended_jinja_environment import ExtendedEnvironment
+
+from jinja2 import DictLoader
+
+
+_V1_ENV = ExtendedEnvironment(loader=DictLoader(_V1_CONTENT))
diff --git a/django_spinproject/modules/pytest_data/v1.py b/django_spinproject/modules/pytest_data/v1.py
new file mode 100644
index 0000000..a709b34
--- /dev/null
+++ b/django_spinproject/modules/pytest_data/v1.py
@@ -0,0 +1,11 @@
+_CONTENT = {
+	'pytest.ini': '''[pytest]
+DJANGO_SETTINGS_MODULE = {{ name }}.settings
+python_files = tests.py test_*.py *_tests.py *_test.py
+# feel free to replace `term` with `term:skip-covered`
+addopts = --cov=. --cov-config=.coveragerc --cov-report term --cov-report html --cov-report xml
+''',
+	'.coveragerc': '''[run]
+omit = */migrations/*, {{ name }}/*, manage.py
+'''
+}