diff --git a/.gitignore b/.gitignore index b6e4761..afd0044 100644 --- a/.gitignore +++ b/.gitignore @@ -127,3 +127,6 @@ dmypy.json # Pyre type checker .pyre/ + +# Pycharm stuff +*.idea* diff --git a/README.md b/README.md index 315d3f7..7628309 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ All contributors must agree to abide by our [Code of Conduct](https://github.com ## Installation Guide -In order to run this site locally, you'll want to clone this repository and install the requirements: +In order to run this site locally, you'll want to clone this repository and install the requirements (check the [Mac Troubleshooting](#mac-troubleshooting) section if you face any errors): ``` git clone https://github.com/psf/python-in-edu.git @@ -18,7 +18,14 @@ source .venv/bin/activate pip install -r requirements.txt ``` -You can then change directories into the python-in-edu folder and run the following command in the terminal: +You can then change directories into the python-in-edu folder and build the database: + +``` +python manage.py migrate +``` + + +To run the project locally, run the following command in the terminal: ``` python manage.py runserver @@ -43,3 +50,28 @@ If you want to use or test email functionality locally, you'll need to [run a si ## Notes We use the [Spirit project](https://spirit-project.com/) for our forums. + +--- + +

Mac Troubleshooting

+ +### Postgres + +If you don't have an installation of Postgres on your system, you might run into the following error: + +``` +Error: pg_config executable not found. +``` + +[Install Postgres](https://postgresapp.com/) to resolve this issue. + +### Pillow + +If your Pillow installation fails during installing the requirements with the following message: + +``` +The headers or library files could not be found for jpeg, +a required dependency when compiling Pillow from source. +``` + +You can resolve this by installing [jpeg](https://formulae.brew.sh/formula/jpeg) using [homebrew](https://brew.sh/). \ No newline at end of file diff --git a/python-in-edu/mysite/settings.py b/python-in-edu/mysite/settings.py index bc7728f..076609c 100644 --- a/python-in-edu/mysite/settings.py +++ b/python-in-edu/mysite/settings.py @@ -171,6 +171,9 @@ } } +if 'FORCE_HTTPS' in os.environ: # running on heroku + SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') + SECURE_SSL_REDIRECT = True # Password validation # https://docs.djangoproject.com/en/3.1/ref/settings/#auth-password-validators diff --git a/python-in-edu/resources/admin.py b/python-in-edu/resources/admin.py index a31e416..2948c70 100644 --- a/python-in-edu/resources/admin.py +++ b/python-in-edu/resources/admin.py @@ -1,7 +1,175 @@ from django.contrib import admin -from .models import Profile, Resource +from .models import ( + Profile, + Resource, + Author, + ProfilePopulation, + ProfileRole, + ResourceLanguage, + ResourceUseType, + ResourceAudience, + ResourceType, + SignupChoice, + ResourceStatus, + Device, + Organization +) -admin.site.register(Profile) -admin.site.register(Resource) \ No newline at end of file +@admin.register(Profile) +class ProfileAdmin(admin.ModelAdmin): + list_display = [ + 'user', + 'organization', + 'country', + 'populations', + 'psf_member' + ] + list_filter = [ + 'user', + 'organization', + 'country', + 'populations', + 'psf_member' + ] + +@admin.register(Author) +class AuthorAdmin(admin.ModelAdmin): + list_display = [ + 'name' + ] + +@admin.register(ProfilePopulation) +class ProfilePopulationAdmin(admin.ModelAdmin): + list_display = [ + 'name', + 'active', + 'underrepresented' + ] + list_filter = [ + 'name', + 'active', + 'underrepresented' + ] + +@admin.register(ProfileRole) +class ProfileRoleAdmin(admin.ModelAdmin): + list_display = [ + 'name', + 'active' + ] + list_filter = [ + 'name', + 'active' + ] + + +@admin.register(Resource) +class ResourceAdmin(admin.ModelAdmin): + list_display = [ + 'title', + 'submitter', + 'status', + 'description' + ] + list_filter = [ + 'submitter', + 'status' + ] + +@admin.register(ResourceLanguage) +class ResourceLanguageAdmin(admin.ModelAdmin): + list_display = [ + 'name', + 'description', + 'active' + ] + list_filter = [ + 'name', + 'active' + ] + + +@admin.register(ResourceUseType) +class ResourceUseTypeAdmin(admin.ModelAdmin): + list_display = [ + 'name', + 'description', + 'active' + ] + list_filter = [ + 'active' + ] + + +@admin.register(ResourceAudience) +class ResourceAudienceAdmin(admin.ModelAdmin): + list_display = [ + 'name', + 'description', + 'active' + ] + list_filter = [ + 'active' + ] + + +@admin.register(ResourceType) +class ResourceTypeAdmin(admin.ModelAdmin): + list_display = [ + 'name', + 'description', + 'active' + ] + list_filter = [ + 'active' + ] + + +@admin.register(SignupChoice) +class SignupChoiceAdmin(admin.ModelAdmin): + list_display = [ + 'name', + 'description', + 'active' + ] + list_filter = [ + 'active' + ] + + +@admin.register(ResourceStatus) +class ResourceStatusAdmin(admin.ModelAdmin): + list_display = [ + 'name', + 'description', + 'active', + 'sequence' + ] + list_filter = [ + 'active' + ] + + +@admin.register(Device) +class DeviceAdmin(admin.ModelAdmin): + list_display = [ + 'name', + 'active' + ] + list_filter = [ + 'active' + ] + + +@admin.register(Organization) +class OrganizationAdmin(admin.ModelAdmin): + list_display = [ + 'name', + 'description', + 'active' + ] + list_filter = [ + 'active' + ] diff --git a/python-in-edu/resources/choices.py b/python-in-edu/resources/choices.py deleted file mode 100644 index c08439b..0000000 --- a/python-in-edu/resources/choices.py +++ /dev/null @@ -1,91 +0,0 @@ -from django.utils.translation import gettext_lazy as _ -from django.db import models - - -class ResourceStatusChoices(models.TextChoices): - PROPOSED = 'PR', _('Proposed') - ACCEPTED = 'AC', _('Accepted') - REJECTED = 'RJ', _('Rejected') - WITHDRAWN = 'WD', _('Withdrawn') - - -class ResourceTypeChoices(models.TextChoices): - PLATFORM_APP = 'PA', _('Platform or App') - CURRICULUM = 'CU', _('Curriculum') - TUTORIAL_COURSE = 'TC', _('Tutorial or Course') - BOOK = 'BK', _('Book') - WORKED_EXAMPLE = 'WE', _('Worked Example') - DOCUMENTATION = 'DC', _('Documentation') - OTHER = 'OT', _('Other') - - -class AudienceChoices(models.TextChoices): - K_THROUGH_12 = 'K12', _('K-12') - HIGHER_ED = 'HIE', _('Higher Education') - PROFESSIONAL_TRAINING = 'PFT', _('Professional Training') - NOT_SPECIFIC = 'NSP', _('Not Specific') - OTHER = 'OTH', _('Other') - - -class DeviceChoices(models.TextChoices): - DESKTOP_OR = 'DOL', _('Desktop or Laptop Computer') - CHROMEBOOK_OR = 'CON', _('Chromebook or Other Netbook') - IPAD = 'IPD', _('iPad') - ANDROID_TABLET = 'ATB', _('Android Tablet') - IPHONE = 'IPH', _('iPhone') - ANDROID_PHONE = 'APH', _('Android Phone') - RASPBERRY_PI = 'RSP', _('Raspberry Pi') - MICROCONTROLLERS = 'MCC', _('Microcontroller(s)') - OTHER = 'OTH', _('Other') - - -class UserRoleChoices(models.TextChoices): - STUDENT = 'STD', _('Student') - PS_EDUCATOR_SCHOOL = 'PES', _('Primary/Secondary Educator (school setting)') - PS_EDUCATOR_OO_SCHOOL = 'PEO', _('Primary/Secondary Educator (out of school setting)') - TEACHING_FACULTY = 'TF', _('Teaching Faculty (post-secondary)') - ADULT_EDU_BOOTCAMP_ETC = 'AEB', _('Adult Educator or Trainer (bootcamp, industry)') - ADULT_EDU_COACHING_ETC = 'AEC', _('Adult Educator or Trainer (teacher PD, coaching)') - CURRICULUM_DEVELOPER = 'CUR', _('Curriculum or Product Developer') - EDUCATION_VOLUNTEER = 'VOL', _('Education Volunteer') - RESEARCHER = 'RES', _('Researcher') - MENTOR = 'MNT', _('Mentor') - INDUSTRY_PROF = 'INP', _('Industry Professional (Tech/Software/CS)') - EDUCATION_DEVELOPER = 'EDV', _('Educational program developer') - PARENT = 'PRT', _('Parent supporting education') - OTHER = 'OTH', _('Other') - - -class PopulationChoices(models.TextChoices): - PRIMARY = 'PRI', _('Primary') - SECONDAY = 'SEC', _('Secondary ') - COLLEGE = 'COL', _('College/University') - ADULT = 'ADU', _('Adult/Professional') - OTHER = 'OTH', _('Other') - NONE = 'NON', _('None') - - -class UseTypeChoices(models.TextChoices): - OPEN_SOURCE_PROJECT = 'OSP', _('Open Source Project - accepts contributions') - OPEN_EDUCATION_RESOURCE = 'OER', _('Open Education Resource - ok to distribute and/or revise/remix') - FREE_RESOURCE = 'FRE', _('Free Resource - free to use') - FREEMIUM = 'IUM', _('Freemium - significant portion of resource free to use') - PAID = 'PAI', _('Paid - costs money to access this resource') - UNKOWN = 'UNK', _('Bleh') - -class PythonChoices(models.TextChoices): - PYTHON_SPECIFIC = 'PS', _('Python Specific - part or all of resource is Python specific') - LANGUAGE_AGNOSTIC = 'LA', _('Language Agnostic - can be used with any programming language') - UNKNOWN = 'UN',_('Unkown') - -class SignUpChoices(models.TextChoices): - CREATE_ACCOUNT = 'CA', _('Must create an account') - PROVIDE_EMAIL = 'PE', _('Must provide email address') - NO_REQUIREMENT = 'NR',_('No sign up requirement') - - - - - - - diff --git a/python-in-edu/resources/forms.py b/python-in-edu/resources/forms.py new file mode 100644 index 0000000..72fff58 --- /dev/null +++ b/python-in-edu/resources/forms.py @@ -0,0 +1,41 @@ +from django import forms +from .models import Resource, ResourceType, ResourceAudience, Device, ResourceUseType, ResourceLanguage + + +class CreateResourceForm(forms.ModelForm): + class Meta: + model = Resource + # fields = '__all__' + fields = [ + 'title', + 'description', + 'requires_signup', + 'license', + ] + + description = forms.Textarea() + + resource_types = forms.ModelMultipleChoiceField( + queryset=ResourceType.objects.all(), + widget=forms.CheckboxSelectMultiple + ) + + audience = forms.ModelMultipleChoiceField( + queryset=ResourceAudience.objects.all(), + widget=forms.CheckboxSelectMultiple + ) + + devices = forms.ModelMultipleChoiceField( + queryset=Device.objects.all(), + widget=forms.CheckboxSelectMultiple + ) + + use_type = forms.ModelMultipleChoiceField( + queryset=ResourceUseType.objects.all(), + widget=forms.CheckboxSelectMultiple + ) + + languages = forms.ModelMultipleChoiceField( + queryset=ResourceLanguage.objects.all(), + widget=forms.CheckboxSelectMultiple + ) diff --git a/python-in-edu/resources/migrations/0009_auto_20210516_1818.py b/python-in-edu/resources/migrations/0009_auto_20210516_1818.py new file mode 100644 index 0000000..bc46036 --- /dev/null +++ b/python-in-edu/resources/migrations/0009_auto_20210516_1818.py @@ -0,0 +1,28 @@ +# Generated by Django 3.1.6 on 2021-05-16 18:18 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('resources', '0008_auto_20210512_1226'), + ] + + operations = [ + migrations.RemoveField( + model_name='resource', + name='attribution', + ), + migrations.CreateModel( + name='Author', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('creation_timestamp', models.DateTimeField(auto_now_add=True)), + ('update_timestamp', models.DateTimeField(auto_now=True)), + ('resource', models.ForeignKey(on_delete=django.db.models.deletion.DO_NOTHING, to='resources.resource')), + ], + ), + ] diff --git a/python-in-edu/resources/migrations/0010_auto_20210516_1822.py b/python-in-edu/resources/migrations/0010_auto_20210516_1822.py new file mode 100644 index 0000000..d97aea1 --- /dev/null +++ b/python-in-edu/resources/migrations/0010_auto_20210516_1822.py @@ -0,0 +1,22 @@ +# Generated by Django 3.1.6 on 2021-05-16 18:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('resources', '0009_auto_20210516_1818'), + ] + + operations = [ + migrations.RemoveField( + model_name='author', + name='resource', + ), + migrations.AddField( + model_name='resource', + name='author', + field=models.ManyToManyField(to='resources.Author'), + ), + ] diff --git a/python-in-edu/resources/migrations/0011_auto_20210520_2114.py b/python-in-edu/resources/migrations/0011_auto_20210520_2114.py new file mode 100644 index 0000000..f5635ef --- /dev/null +++ b/python-in-edu/resources/migrations/0011_auto_20210520_2114.py @@ -0,0 +1,242 @@ +# Generated by Django 3.1.6 on 2021-05-20 21:14 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('resources', '0010_auto_20210516_1822'), + ] + + operations = [ + migrations.CreateModel( + name='Device', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('active', models.BooleanField(default=True)), + ('creation_timestamp', models.DateTimeField(auto_now_add=True)), + ('update_timestamp', models.DateTimeField(auto_now=True)), + ], + ), + migrations.CreateModel( + name='Organization', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50)), + ('description', models.CharField(max_length=200)), + ('creation_timestamp', models.DateTimeField(auto_now_add=True)), + ('update_timestamp', models.DateTimeField(auto_now=True)), + ('active', models.BooleanField(default=True)), + ], + ), + migrations.CreateModel( + name='ProfilePopulation', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('active', models.BooleanField(default=True)), + ('underrepresented', models.BooleanField(default=False)), + ('creation_timestamp', models.DateTimeField(auto_now_add=True)), + ('update_timestamp', models.DateTimeField(auto_now=True)), + ], + ), + migrations.CreateModel( + name='ProfileRole', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('active', models.BooleanField(default=True)), + ('creation_timestamp', models.DateTimeField(auto_now_add=True)), + ('update_timestamp', models.DateTimeField(auto_now=True)), + ], + ), + migrations.CreateModel( + name='ResourceAudience', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50)), + ('description', models.CharField(max_length=200)), + ('creation_timestamp', models.DateTimeField(auto_now_add=True)), + ('update_timestamp', models.DateTimeField(auto_now=True)), + ('active', models.BooleanField(default=True)), + ], + ), + migrations.CreateModel( + name='ResourceLanguages', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50)), + ('description', models.CharField(max_length=200)), + ('creation_timestamp', models.DateTimeField(auto_now_add=True)), + ('update_timestamp', models.DateTimeField(auto_now=True)), + ('active', models.BooleanField(default=True)), + ], + ), + migrations.CreateModel( + name='ResourceStatus', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50)), + ('description', models.CharField(max_length=200)), + ('creation_timestamp', models.DateTimeField(auto_now_add=True)), + ('update_timestamp', models.DateTimeField(auto_now=True)), + ('active', models.BooleanField(default=True)), + ], + ), + migrations.CreateModel( + name='ResourceType', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50)), + ('description', models.CharField(max_length=200)), + ('creation_timestamp', models.DateTimeField(auto_now_add=True)), + ('update_timestamp', models.DateTimeField(auto_now=True)), + ('active', models.BooleanField(default=True)), + ], + ), + migrations.CreateModel( + name='ResourceUseType', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50)), + ('description', models.CharField(max_length=200)), + ('creation_timestamp', models.DateTimeField(auto_now_add=True)), + ('update_timestamp', models.DateTimeField(auto_now=True)), + ('active', models.BooleanField(default=True)), + ], + ), + migrations.CreateModel( + name='SignupChoice', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50)), + ('description', models.CharField(max_length=200)), + ('creation_timestamp', models.DateTimeField(auto_now_add=True)), + ('update_timestamp', models.DateTimeField(auto_now=True)), + ('active', models.BooleanField(default=True)), + ], + ), + migrations.RemoveField( + model_name='profile', + name='underrep', + ), + migrations.RemoveField( + model_name='resource', + name='contact', + ), + migrations.RemoveField( + model_name='resource', + name='python_related', + ), + migrations.RemoveField( + model_name='resource', + name='resource_type', + ), + migrations.RemoveField( + model_name='resource', + name='url1', + ), + migrations.RemoveField( + model_name='resource', + name='url2', + ), + migrations.RemoveField( + model_name='resource', + name='url3', + ), + migrations.RemoveField( + model_name='resource', + name='url_description1', + ), + migrations.RemoveField( + model_name='resource', + name='url_description2', + ), + migrations.RemoveField( + model_name='resource', + name='url_description3', + ), + migrations.AddField( + model_name='author', + name='biography', + field=models.TextField(blank=True, null=True), + ), + migrations.RemoveField( + model_name='resource', + name='audience', + ), + migrations.RemoveField( + model_name='resource', + name='devices', + ), + migrations.AlterField( + model_name='resource', + name='submitter', + field=models.ForeignKey(on_delete=django.db.models.deletion.DO_NOTHING, to=settings.AUTH_USER_MODEL), + ), + migrations.RemoveField( + model_name='resource', + name='use_type', + ), + migrations.CreateModel( + name='ResourceURL', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('url', models.URLField()), + ('description', models.TextField()), + ('creation_timestamp', models.DateTimeField(auto_now_add=True)), + ('update_timestamp', models.DateTimeField(auto_now=True)), + ('resource', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='url_resource', to='resources.resource')), + ], + ), + migrations.AddField( + model_name='resource', + name='languages', + field=models.ManyToManyField(help_text='Choose the languages that your resource focuses on.', limit_choices_to={'active': True}, to='resources.ResourceLanguages'), + ), + migrations.AddField( + model_name='resource', + name='resource_types', + field=models.ManyToManyField(help_text='Select all that apply.', limit_choices_to={'active': True}, to='resources.ResourceType'), + ), + migrations.AlterField( + model_name='profile', + name='populations', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='resources.profilepopulation'), + ), + migrations.AlterField( + model_name='profile', + name='roles', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='resources.profilerole'), + ), + migrations.AddField( + model_name='resource', + name='audience', + field=models.ManyToManyField(help_text="Select 'not specific' for resources for any or all audiences.", limit_choices_to={'active': True}, to='resources.ResourceAudience'), + ), + migrations.AddField( + model_name='resource', + name='devices', + field=models.ManyToManyField(help_text='Which devices are compatible with this resource', limit_choices_to={'active': True}, to='resources.Device'), + ), + migrations.AlterField( + model_name='resource', + name='requires_signup', + field=models.ForeignKey(help_text='Are users required to create an account or provide their email address to access this resource?', on_delete=django.db.models.deletion.PROTECT, to='resources.signupchoice'), + ), + migrations.AlterField( + model_name='resource', + name='status', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='resources.resourcestatus'), + ), + migrations.AddField( + model_name='resource', + name='use_type', + field=models.ManyToManyField(help_text='Select the use type that best describes this resource.', limit_choices_to={'active': True}, to='resources.ResourceUseType'), + ), + ] diff --git a/python-in-edu/resources/migrations/0012_auto_20210520_2251.py b/python-in-edu/resources/migrations/0012_auto_20210520_2251.py new file mode 100644 index 0000000..410d52d --- /dev/null +++ b/python-in-edu/resources/migrations/0012_auto_20210520_2251.py @@ -0,0 +1,22 @@ +# Generated by Django 3.1.6 on 2021-05-20 22:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('resources', '0011_auto_20210520_2114'), + ] + + operations = [ + migrations.RemoveField( + model_name='resource', + name='language', + ), + migrations.AddField( + model_name='resourcestatus', + name='sequence', + field=models.IntegerField(default=1), + ), + ] diff --git a/python-in-edu/resources/migrations/0013_auto_20210521_0415.py b/python-in-edu/resources/migrations/0013_auto_20210521_0415.py new file mode 100644 index 0000000..80f63e9 --- /dev/null +++ b/python-in-edu/resources/migrations/0013_auto_20210521_0415.py @@ -0,0 +1,23 @@ +# Generated by Django 3.1.6 on 2021-05-21 04:15 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('resources', '0012_auto_20210520_2251'), + ] + + operations = [ + migrations.RenameModel( + old_name='ResourceLanguages', + new_name='ResourceLanguage', + ), + migrations.AlterField( + model_name='resource', + name='status', + field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.PROTECT, to='resources.resourcestatus'), + ), + ] diff --git a/python-in-edu/resources/migrations/0014_auto_20210607_1959.py b/python-in-edu/resources/migrations/0014_auto_20210607_1959.py new file mode 100644 index 0000000..483b581 --- /dev/null +++ b/python-in-edu/resources/migrations/0014_auto_20210607_1959.py @@ -0,0 +1,125 @@ +# Generated by Django 3.1.6 on 2021-06-07 19:59 + +from django.db import migrations + +def load_organizations(apps, schema_editor): + Organization = apps.get_model('resources', 'Organization') + pass + +def load_profile_roles(apps, schema_editor): + ProfileRole = apps.get_model('resources', 'ProfileRole') + + ProfileRole.objects.get_or_create(pk=1, name='Student', active=True) + ProfileRole.objects.get_or_create(pk=2, name='Educator', active=True) + pass + +def load_profile_populations(apps, schema_editor): + ProfilePopulation = apps.get_model('resources', 'ProfilePopulation') + + ProfilePopulation.objects.get_or_create(pk=1, name='Primary', active=True, underrepresented=False) + ProfilePopulation.objects.get_or_create(pk=2, name='Secondary', active=True, underrepresented=False) + ProfilePopulation.objects.get_or_create(pk=3, name='College', active=True, underrepresented=False) + ProfilePopulation.objects.get_or_create(pk=4, name='Adult', active=True, underrepresented=False) + ProfilePopulation.objects.get_or_create(pk=5, name='Other', active=True, underrepresented=False) + ProfilePopulation.objects.get_or_create(pk=6, name='None', active=True, underrepresented=False) + +def load_devices(apps, schema_editor): + Device = apps.get_model('resources', 'Device') + + Device.objects.get_or_create(pk=1, name='IPhone', active=True) + Device.objects.get_or_create(pk=2, name='Desktop/Laptop', active=True) + Device.objects.get_or_create(pk=3, name='Chromebook', active=True) + Device.objects.get_or_create(pk=4, name='IPad', active=True) + Device.objects.get_or_create(pk=5, name='Android Tablet', active=True) + Device.objects.get_or_create(pk=6, name='Android Phone', active=True) + Device.objects.get_or_create(pk=7, name='Raspberry Pi', active=True) + Device.objects.get_or_create(pk=8, name='Microcontrollers', active=True) + +def load_resource_statuses(apps, schema_editor): + ResourceStatus = apps.get_model('resources', 'ResourceStatus') + + ResourceStatus.objects.get_or_create(pk=1, name='Proposed', + description='Resource has been proposed and is in review.', active=True, + sequence=1) + ResourceStatus.objects.get_or_create(pk=2, name='Accepted', + description='Resource has been reviewed and accepted.', active=True, + sequence=2) + ResourceStatus.objects.get_or_create(pk=3, name='Rejected', + description='Resource has been reviewed and rejected.', active=True, + sequence=3) + ResourceStatus.objects.get_or_create(pk=4, name='Withdrawn', + description='Resource has been withdrawn from review.', active=True, + sequence=4) + + +def load_signup_choices(apps, schema_editor): + SignupChoice = apps.get_model('resources', 'SignupChoice') + + SignupChoice.objects.get_or_create(pk=1, name='Create Account', description='Must create an account', active=True) + SignupChoice.objects.get_or_create(pk=2, name='Provide Email', description='Must provide email address', active=True) + SignupChoice.objects.get_or_create(pk=3, name='No Requirement', description='No sign up requirement', active=True) + +def load_resource_types(apps, schema_editor): + ResourceType = apps.get_model('resources', 'ResourceType') + + ResourceType.objects.get_or_create(pk=1, name='Platform or App', + description='Resource is specific to a platform or app', active=True) + ResourceType.objects.get_or_create(pk=2, name='Curriculum', + description='Resource is intended to be part of a curriculum.', active=True) + ResourceType.objects.get_or_create(pk=3, name='Tutorial', + description='Resource is part of a tutorial course.', active=True) + ResourceType.objects.get_or_create(pk=4, name='Book', + description='Resource is included as part of a book.', active=True) + ResourceType.objects.get_or_create(pk=5, name='Worked Example', + description='Resource is part of a worked example.', active=True) + ResourceType.objects.get_or_create(pk=6, name='Documentation', + description='Resource is part of documentation.', active=True) + + +def load_resource_audiences(apps, schema_editor): + ResourceAudience = apps.get_model('resources', 'ResourceAudience') + + ResourceAudience.objects.get_or_create(pk=1, name='K-12', description='K-12', active=True) + ResourceAudience.objects.get_or_create(pk=2, name='Higher Education', description='College/Secondary School', active=True) + ResourceAudience.objects.get_or_create(pk=3, name='Professional Training', description='Meant for professionals', active=True) + ResourceAudience.objects.get_or_create(pk=4, name='General', description='General audience', active=True) + +def load_resource_use_types(apps, schema_editor): + ResourceUseType = apps.get_model('resources', 'ResourceUseType') + + ResourceUseType.objects.get_or_create(pk=1, name='Open Source Project', + description='Open Source Project - accepts contributions', active=True) + ResourceUseType.objects.get_or_create(pk=2, name='Open Education Resource', + description='Open Education Resource - ok to distribute and/or revise/remix.', active=True) + ResourceUseType.objects.get_or_create(pk=3, name='Free Resource', + description='Free Resource - Free to use', active=True) + ResourceUseType.objects.get_or_create(pk=4, name='Freemium', + description='Freemium - significant portion of resource free to use', active=True) + ResourceUseType.objects.get_or_create(pk=5, name='Paid', + description='Paid - costs money to access this resource', active=True) + + +def load_resource_languages(apps, schema_editor): + ResourceLanguage = apps.get_model('resources', 'ResourceLanguage') + + ResourceLanguage.objects.get_or_create(pk=1, name='Python', description='Best language ever!', active=True) + + +class Migration(migrations.Migration): + + dependencies = [ + ('resources', '0013_auto_20210521_0415'), + ] + + operations = [ + migrations.RunPython(load_organizations), + migrations.RunPython(load_profile_roles), + migrations.RunPython(load_profile_populations), + migrations.RunPython(load_devices), + migrations.RunPython(load_resource_statuses), + migrations.RunPython(load_signup_choices), + migrations.RunPython(load_resource_types), + migrations.RunPython(load_resource_audiences), + migrations.RunPython(load_resource_use_types), + migrations.RunPython(load_resource_languages) + ] diff --git a/python-in-edu/resources/migrations/0015_auto_20210608_2119.py b/python-in-edu/resources/migrations/0015_auto_20210608_2119.py new file mode 100644 index 0000000..7afdc40 --- /dev/null +++ b/python-in-edu/resources/migrations/0015_auto_20210608_2119.py @@ -0,0 +1,19 @@ +# Generated by Django 3.1.6 on 2021-06-08 21:19 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('resources', '0014_auto_20210607_1959'), + ] + + operations = [ + migrations.AlterField( + model_name='resource', + name='status', + field=models.ForeignKey(blank=True, default=1, null=True, on_delete=django.db.models.deletion.PROTECT, to='resources.resourcestatus'), + ), + ] diff --git a/python-in-edu/resources/models.py b/python-in-edu/resources/models.py index c08eff0..29fa2ce 100644 --- a/python-in-edu/resources/models.py +++ b/python-in-edu/resources/models.py @@ -4,24 +4,56 @@ from django.core.mail import send_mail from django.urls import reverse -from multiselectfield import MultiSelectField - from mysite.settings import DEFAULT_FROM_EMAIL -from . import choices +def get_initial_status(): + try: + initial_status = ResourceStatus.objects.get(sequence=1).id + except ResourceStatus.DoesNotExist: + initial_status = None + return initial_status + # Profile Models +class Organization(models.Model): + name = models.CharField(max_length=50) + description = models.CharField(max_length=200) + creation_timestamp = models.DateTimeField(auto_now_add=True) + update_timestamp = models.DateTimeField(auto_now=True) + active = models.BooleanField(default=True) + + def __str__(self): + return f'{self.name}-{self.description}' -class Profile(models.Model): +class ProfileRole(models.Model): + name = models.CharField(max_length=100) + active = models.BooleanField(default=True) + creation_timestamp = models.DateTimeField(auto_now_add=True) + update_timestamp = models.DateTimeField(auto_now=True) + + def __str__(self): + return f'{self.name}' + + +class ProfilePopulation(models.Model): + name = models.CharField(max_length=100) + active = models.BooleanField(default=True) + underrepresented = models.BooleanField(default=False) + creation_timestamp = models.DateTimeField(auto_now_add=True) + update_timestamp = models.DateTimeField(auto_now=True) + + def __str__(self): + return f'{self.name}' + +class Profile(models.Model): user = models.OneToOneField(User, on_delete=models.CASCADE) organization = models.CharField(max_length=100, blank=True, null=True) country = models.CharField(max_length=100, blank=True, null=True) - roles = MultiSelectField(choices=choices.UserRoleChoices.choices) - populations = MultiSelectField(choices=choices.PopulationChoices.choices) - underrep = models.BooleanField(default=False) - psf_member = models.BooleanField(default=False) + roles = models.ForeignKey(ProfileRole, on_delete=models.PROTECT) + populations = models.ForeignKey(ProfilePopulation, on_delete=models.PROTECT) + psf_member = models.BooleanField(default=False) #TODO couldn't this be automatically defined with data from PSF? def __str__(self): return f"{self.user.username}" @@ -36,49 +68,126 @@ def create_user_profile(sender, instance, created, **kwargs): # Resource Models -#class Link(models.Model): +class Author(models.Model): + name = models.CharField(max_length=100) + biography = models.TextField(blank=True, null=True) + creation_timestamp = models.DateTimeField(auto_now_add=True) + update_timestamp = models.DateTimeField(auto_now=True) + def __str__(self): + return self.name -class Resource(models.Model): - #Required and optional fields - url1 = models.CharField(max_length=200, help_text="You must link at least one resource.") - url_description1 = models.CharField(max_length=50, blank=True, null=True, help_text="Use this field, if you are including multiple urls") - # resource = models.ForeignKey('Resource', on_delete=models.CASCADE, related_name='links') - url2 = models.CharField(max_length=200, blank=True, null=True, help_text="Optional additional url related to the same resource") - url_description2 = models.CharField(max_length=50, blank=True, null=True, help_text="Use this field, if you are including multiple urls") - # resource = models.ForeignKey('Resource', on_delete=models.CASCADE, related_name='links') - url3 = models.CharField(max_length=200, blank=True, null=True, help_text="Optional additional url related to the same resource") - url_description3 = models.CharField(max_length=50, blank=True, null=True, help_text="Use this field, if you are including multiple urls") - # resource = models.ForeignKey('Resource', on_delete=models.CASCADE, related_name='links') +class Device(models.Model): + name = models.CharField(max_length=100) + active = models.BooleanField(default=True) + creation_timestamp = models.DateTimeField(auto_now_add=True) + update_timestamp = models.DateTimeField(auto_now=True) + + def __str__(self): + return f'{self.name}' + + +class ResourceStatus(models.Model): + name = models.CharField(max_length=50) + description = models.CharField(max_length=200) + creation_timestamp = models.DateTimeField(auto_now_add=True) + update_timestamp = models.DateTimeField(auto_now=True) + active = models.BooleanField(default=True) + sequence = models.IntegerField(default=1) + + def __str__(self): + return f'{self.name}' + + +class SignupChoice(models.Model): + name = models.CharField(max_length=50) + description = models.CharField(max_length=200) + creation_timestamp = models.DateTimeField(auto_now_add=True) + update_timestamp = models.DateTimeField(auto_now=True) + active = models.BooleanField(default=True) + + def __str__(self): + return f'{self.name}' + + +class ResourceType(models.Model): + name = models.CharField(max_length=50) + description = models.CharField(max_length=200) + creation_timestamp = models.DateTimeField(auto_now_add=True) + update_timestamp = models.DateTimeField(auto_now=True) + active = models.BooleanField(default=True) + + def __str__(self): + return f'{self.name}' + + +class ResourceAudience(models.Model): + name = models.CharField(max_length=50) + description = models.CharField(max_length=200) + creation_timestamp = models.DateTimeField(auto_now_add=True) + update_timestamp = models.DateTimeField(auto_now=True) + active = models.BooleanField(default=True) + + def __str__(self): + return f'{self.name}' + + +class ResourceUseType(models.Model): + name = models.CharField(max_length=50) + description = models.CharField(max_length=200) + creation_timestamp = models.DateTimeField(auto_now_add=True) + update_timestamp = models.DateTimeField(auto_now=True) + active = models.BooleanField(default=True) + + def __str__(self): + return f'{self.name}' + + +class ResourceLanguage(models.Model): + name = models.CharField(max_length=50) + description = models.CharField(max_length=200) + creation_timestamp = models.DateTimeField(auto_now_add=True) + update_timestamp = models.DateTimeField(auto_now=True) + active = models.BooleanField(default=True) + + def __str__(self): + return f'{self.name}' + +class Resource(models.Model): # core fields title = models.CharField(max_length=200, help_text="What is the name of the resource") - submitter = models.ForeignKey(User, on_delete=models.CASCADE) # FIXME: probably want to orphan rather than delete - status = models.CharField(max_length=3, choices=choices.ResourceStatusChoices.choices, default=choices.ResourceStatusChoices.PROPOSED) + submitter = models.ForeignKey(User, on_delete=models.DO_NOTHING) + status = models.ForeignKey(ResourceStatus, on_delete=models.PROTECT, default=get_initial_status(), blank=True, null=True) # required fields - requires_signup = models.CharField(max_length=3,choices=choices.SignUpChoices.choices, help_text="Are users required to create an account or provide their email address to access this resource?") - resource_type = MultiSelectField(max_length=30,choices=choices.ResourceTypeChoices.choices, help_text="Select all that apply.") - audience = MultiSelectField(max_length=30,choices=choices.AudienceChoices.choices, help_text="Select 'not specific' for resources for any or all audiences.") - devices = MultiSelectField(max_length=30,choices=choices.DeviceChoices.choices, help_text="Which devices are compatible with this resource") + requires_signup = models.ForeignKey(SignupChoice, on_delete=models.PROTECT, help_text="Are users required to create an account or provide their email address to access this resource?") + resource_types = models.ManyToManyField(ResourceType, help_text="Select all that apply.", limit_choices_to={'active': True}) + audience = models.ManyToManyField(ResourceAudience, help_text="Select 'not specific' for resources for any or all audiences.", limit_choices_to={'active': True}) + devices = models.ManyToManyField(Device, help_text="Which devices are compatible with this resource", limit_choices_to={'active': True}) description = models.CharField(max_length=500, help_text="Add a description of this resource. (max 500 characters)") - attribution = models.CharField(max_length=250, help_text="What person or organization created this resource?") - use_type = models.CharField(max_length=3, choices=choices.UseTypeChoices.choices, help_text="Select the use type that best describes this resource.", default=choices.PythonChoices.UNKNOWN) - python_related = models.CharField(max_length=2, choices=choices.PythonChoices.choices, help_text="Select the option that best describes this resource.", default=choices.PythonChoices.UNKNOWN) + author = models.ManyToManyField(Author) + use_type = models.ManyToManyField(ResourceUseType, help_text="Select the use type that best describes this resource.", limit_choices_to={'active': True}) # optional fields - - #author_bio = models.CharField(max_length=250, blank=True, null=True) - #organization = models.CharField(max_length=250, blank=True, null=True) - contact = models.CharField(max_length=250, blank=True, null=True, help_text="Not for display, What is the best way to reach you if we have questions about this submission?") - #standards = models.CharField(max_length=250, blank=True, null=True) - language = models.CharField(max_length=50, blank=True, null=True, help_text="What language/s are the written materials available in?") - #requirements = models.CharField(max_length=200, blank=True, null=True) + languages = models.ManyToManyField(ResourceLanguage, help_text="Choose the languages that your resource focuses on.", limit_choices_to={'active': True}) + + # TODO replace the contact field with contact information against the Profile model license = models.CharField(max_length=200, blank=True, null=True, help_text="What is the copyright license type? Type 'unknown' if the license type is not available.") def __str__(self): - return f"{self.title} (submitted by {self.submitter}) - {self.get_status_display()}" + return f"{self.title} (submitted by {self.submitter}) - {self.status.name}" + + +class ResourceURL(models.Model): + resource = models.ForeignKey(Resource, on_delete=models.CASCADE, related_name='url_resource') + url = models.URLField(max_length=200) + description = models.TextField() + creation_timestamp = models.DateTimeField(auto_now_add=True) + update_timestamp = models.DateTimeField(auto_now=True) + + def resource_updated(sender, instance, created, **kwargs): diff --git a/python-in-edu/resources/templates/authors/author_create.html b/python-in-edu/resources/templates/authors/author_create.html new file mode 100644 index 0000000..b575198 --- /dev/null +++ b/python-in-edu/resources/templates/authors/author_create.html @@ -0,0 +1,11 @@ +{% extends "resources/base.html" %} + +{% block page_title %}Add Author{% endblock page_title %} + +{% block content %} +
+ {% csrf_token %} + {{ form.as_p }} + +
+{% endblock %} \ No newline at end of file diff --git a/python-in-edu/resources/templates/authors/author_list.html b/python-in-edu/resources/templates/authors/author_list.html new file mode 100644 index 0000000..bc1d5e6 --- /dev/null +++ b/python-in-edu/resources/templates/authors/author_list.html @@ -0,0 +1,151 @@ +{% extends "resources/base.html" %} + +{% block page_title %}Resources{% endblock page_title %} + +{% block full_width_content %} + +

Authors

+ + {% include 'text_includes/author_search_text.html' %} + + {% if user.is_authenticated %} + + + + + + {% endif %} +
+ + + + + + + + + {% for author in author_list %} + + + + + + {% endfor %} + + +
Author Name
+ {{ author.name }} +
+
+ + + + + + + +{% endblock full_width_content %} + diff --git a/python-in-edu/resources/templates/resources/resource_list.html b/python-in-edu/resources/templates/resources/resource_list.html index d2e5f05..0b8ed32 100644 --- a/python-in-edu/resources/templates/resources/resource_list.html +++ b/python-in-edu/resources/templates/resources/resource_list.html @@ -38,7 +38,6 @@

Resources

Use Type Requirements License - Standards @@ -46,7 +45,7 @@

Resources

{% for resource in resource_list %} Resources {{ resource.title }} {{ resource.submitter }} - {{ resource.author_bio|truncatechars:10 }} + + {% for author in resource.author.all %} + {{ author.name }} + {% endfor %} +

{{ resource.description|truncatechars:30 }}

- {{ resource.get_audience_display }} - {{ resource.get_resource_type_display }} - {{ resource.get_devices_display }} + + {% for audience in resource.audience.all %} + {{ audience.name }} + {% endfor %} + + + {% for resource_type in resource.resource_types.all %} + {{ resource_type.name }} + {% endfor %} + + + {% for device in resource.devices.all %} + {{ device.name }} + {% endfor %} + {{ resource.requires_signup}} @@ -70,7 +85,6 @@

Resources

{{ resource.use_type }} {{ resource.requirements }} {{ resource.license }} - {{ resource.standards }} diff --git a/python-in-edu/resources/templates/text_includes/author_search_text.html b/python-in-edu/resources/templates/text_includes/author_search_text.html new file mode 100644 index 0000000..9f6ae8a --- /dev/null +++ b/python-in-edu/resources/templates/text_includes/author_search_text.html @@ -0,0 +1 @@ +Search for Authors \ No newline at end of file diff --git a/python-in-edu/resources/templates/text_includes/getting_started_intro_text.html b/python-in-edu/resources/templates/text_includes/getting_started_intro_text.html index a1913bc..a8d1e04 100644 --- a/python-in-edu/resources/templates/text_includes/getting_started_intro_text.html +++ b/python-in-edu/resources/templates/text_includes/getting_started_intro_text.html @@ -3,9 +3,9 @@ {% load static %}

What Works in Teaching Python Toolkit

Evidence-based teaching strategies

-

Take Action Toolkit

+

Take Action Toolkit

Actions that support CS instruction at the primary and secondary levels

-

Inclusive Teaching Practices Guide

-

A guide to planning with cultural knowledge, linguistic diversity, and learning differences in mind

-

Choosing a Programming Platform Guide

+

Platforms for Teaching Python Guide

A guide to selecting the right programming platform for your educational context

+

Inclusive Teaching Practices Guide - Coming Soon

+

A guide to planning with cultural knowledge, linguistic diversity, and learning differences in mind

diff --git a/python-in-edu/resources/tests.py b/python-in-edu/resources/tests.py index 398a950..be475bd 100644 --- a/python-in-edu/resources/tests.py +++ b/python-in-edu/resources/tests.py @@ -6,7 +6,6 @@ from django.core import mail from .models import Profile, Resource, resource_updated -from . import choices class BaseTestCase(TestCase): @@ -18,9 +17,9 @@ def create_users(self): def create_resources(self): self.basic_resource_data = { "title": "A Title", "url": "www.example.com", "submitter": self.user, - "status": choices.ResourceStatusChoices.PROPOSED, - "resource_type": choices.ResourceTypeChoices.PLATFORM_APP, - "audience": choices.AudienceChoices.K_THROUGH_12, + # "status": choices.ResourceStatusChoices.PROPOSED, + # "resource_type": choices.ResourceTypeChoices.PLATFORM_APP, + # "audience": choices.AudienceChoices.K_THROUGH_12, "language": "English", "requirements": "none", "license": "none" } self.resource_a = Resource.objects.create(**self.basic_resource_data) @@ -35,7 +34,7 @@ class ResourceViewCase(BaseTestCase): def test_resource_list_contains_accepted_only(self): """The resource list contains only accepted resources.""" - self.resource_a.status = choices.ResourceStatusChoices.ACCEPTED + # self.resource_a.status = choices.ResourceStatusChoices.ACCEPTED self.resource_a.save() response = self.client.get(reverse('resource_list')) self.assertEqual(list(response.context['resource_list']), [self.resource_a]) diff --git a/python-in-edu/resources/urls.py b/python-in-edu/resources/urls.py index 740acae..836441c 100644 --- a/python-in-edu/resources/urls.py +++ b/python-in-edu/resources/urls.py @@ -4,6 +4,8 @@ urlpatterns = [ + path('authors/new', views.AuthorCreateView.as_view(), name='author_create'), + path('authors/list', views.AuthorListView.as_view(), name='author_list'), path('profile/', views.ProfileDetailView.as_view(), name='profile_detail'), path('profile//udpate', views.ProfileUpdateView.as_view(), name='profile_update'), path('resource/list', views.ResourceListView.as_view(), name='resource_list'), diff --git a/python-in-edu/resources/views.py b/python-in-edu/resources/views.py index d40bc36..834b8fd 100644 --- a/python-in-edu/resources/views.py +++ b/python-in-edu/resources/views.py @@ -4,11 +4,10 @@ from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.models import User from django.http import HttpResponseRedirect +from .forms import CreateResourceForm -from .models import Profile, Resource -from . import choices - +from .models import Profile, Resource, Author class GettingStartedView(generic.TemplateView): template_name = 'misc/getting_started.html' @@ -22,6 +21,24 @@ class CodeOfConductView(generic.TemplateView): template_name = 'misc/code_of_conduct.html' +class AuthorListView(generic.ListView): + model = Author + template_name = 'authors/author_list.html' + + +class AuthorCreateView(generic.CreateView): + model = Author + fields = '__all__' + template_name = 'authors/author_create.html' + + def get_success_url(self, instance): + return reverse('author_list') + + def form_valid(self, form): + instance = form.save() + return HttpResponseRedirect(self.get_success_url(instance=instance)) + + class ResourceDetailView(generic.DetailView): model = Resource template_name = 'resources/resource_detail.html' @@ -34,14 +51,13 @@ class ResourceListView(generic.ListView): def get_context_data(self, **kwargs): # overrides default to get only accepted resources context = super().get_context_data(**kwargs) - context['resource_list'] = Resource.objects.filter(status=choices.ResourceStatusChoices.ACCEPTED) + context['resource_list'] = Resource.objects.all() return context class ResourceCreateView(LoginRequiredMixin, generic.CreateView): model = Resource - fields = ['title', 'url1', 'url_description1', 'url2', 'url_description2', 'url3', 'url_description3', 'resource_type', 'audience', 'devices', 'requires_signup', 'use_type', 'python_related', - 'description', 'attribution', 'language', 'license', 'contact'] + form_class = CreateResourceForm template_name = 'resources/add_resource.html' def get_success_url(self, instance): @@ -56,8 +72,8 @@ def form_valid(self, form): class ResourceUpdateView(LoginRequiredMixin, generic.UpdateView): model = Resource - fields = ['title', 'url1', 'url_description1', 'url2', 'url_description2', 'url3', 'url_description3', 'resource_type', 'audience', 'devices', 'requires_signup', 'use_type', 'python_related', - 'description', 'attribution', 'language', 'license', 'contact'] + fields = ['title', 'audience', 'devices', 'requires_signup', 'use_type', + 'description', 'author', 'license'] template_name = 'resources/update_resource.html' def get_success_url(self): diff --git a/python-in-edu/static_source/guides/Python-Platform-Comparison.pdf b/python-in-edu/static_source/guides/Python-Platform-Comparison.pdf new file mode 100644 index 0000000..d3ee873 Binary files /dev/null and b/python-in-edu/static_source/guides/Python-Platform-Comparison.pdf differ diff --git a/python-in-edu/static_source/guides/Take-Action-Toolkit.pdf b/python-in-edu/static_source/guides/Take-Action-Toolkit.pdf new file mode 100644 index 0000000..6bdacd4 Binary files /dev/null and b/python-in-edu/static_source/guides/Take-Action-Toolkit.pdf differ diff --git a/python-in-edu/static_source/images/Zen-of-OER.png b/python-in-edu/static_source/images/Zen-of-OER.png new file mode 100644 index 0000000..2185f88 Binary files /dev/null and b/python-in-edu/static_source/images/Zen-of-OER.png differ