简介:这是一套基于Django框架开发的CRM(客户关系管理)系统完整源码,定位为Python中高级开发者、Web后端学习者及需要快速搭建客户管理系统的团队。项目共包含652个文件,压缩包大小10.1MB,核心为204个Python脚本,覆盖模型、视图、路由与业务逻辑;另有202个JPG图片、84个PNG图片、67个HTML模板、16个JavaScript文件、13个CSS样式表,构成完整的前端展示与交互体系;同时提供Dockerfile、Jenkinsfile、YAML配置和Shell脚本,便于部署与自动化集成。已有550人浏览学习,适合用于理解Django项目结构、CRM常见模块(如客户管理、跟进记录、数据分析)的实现套路,也可作为毕业设计或企业内部系统的改造基础。内容预览显示资源内置丰富后台样式(Bootstrap、FontAwesome、富交互组件等),能帮助快速搭建专业界面,是一份实用且完整的Python Web开发参考资料。
1. 一个自带完整链路的Django-CRM:从源码包到能跑的销售中台
原本以为Django-CRM只是给增删改查套了一层后台外壳,直到把这套完整源码跑起来才看清:潜在客户登记、跟进记录、合同审批、回款统计,十几个数据表串成一条完整销售链路,后台还有按月份和渠道聚合的仪表盘。这里讲的“高效”指的是开发效率,Django自带Admin和ORM让大部分接口零代码落地,你拿到手改配置就能部署,真正的工作在数据模型拆法和权限隔离上。适合小团队快速搭出销售中台,也适合刚学Django的开发者拿来拆模型、权限和报表这几块硬骨头。下文只讲怎么把源码跑起来、核心模块怎么看、哪些位置一不留神会翻车,以及我踩过几次之后的固定处理习惯。
2. 先把系统跑起来:环境准备、数据库初始化与后台登录
2.1 项目结构概览与技术选型判断
拿到源码压缩包,不要急着双击打开README,先确认manage.py和最外层配置目录在不在。Django项目看多了会形成一种“肌肉记忆”:manage.py负责命令行入口,配置目录里放着settings和urls,功能模块全部挂在下面的独立app里。CRM系统我见过两种组织风格,一种是把所有模型塞进一个crmapp,文件越来越大,最后admin注册和模型混在一起;另一种是拆成客户、跟进、合同、统计等多个app,迁移文件和URL路由各管各的。这套源码属于后者,customer、followup、contract、dashboard四个app各自独立,信号处理和自定义权限也在自己的目录里。后期扩展某个模块时,不会因为改一个模型触发其他模块的迁移链。
技术栈选型也值得先看一眼:模型层用class-based view和命名路由,说明当前环境至少要Django 3.0以上,Python版本建议3.8或更高。数据库默认配置是SQLite,本地零依赖就能跑起来;真要上生产,把settings里的ENGINE换成django.db.backends.postgresql或mysql,再把NAME、USER、HOST、PORT这些参数填进去即可。Admin后台是全功能自带的那一套,未做深度魔改,所以如果你已经很熟悉Django自带后台,操作零成本。
判断这套源码是否“干净”,我通常会做三个快速检查。第一,打开models.py看外键是否全部指定了on_delete行为,如果还有裸外键,迁移时会有隐患;第二,看settings里有没有把SECRET_KEY写死在源码里,如果写死,说明这份源码只是给开发者本地用的,部署前需要改成环境变量读取;第三,搜索代码里有没有带内置时区的硬编码datetime.now(),因为时区问题会在报表上造成连锁偏移。做完这三个检查,心里基本有数。
2.2 三分钟启动:依赖安装与配置修改
常见做法是先把步骤化整为零:创建虚拟环境、安装依赖、编辑settings、执行迁移。这里最关键的其实是虚拟环境,我见过直接在全局Python里安装依赖,结果把系统环境弄乱,和系统包冲突后难以排查。
python -m venv venv source venv/bin/activate pip install -r requirements.txtvenv目录建议不要提交进版本库。Windows下的激活命令是venv\Scripts\activate,和Linux/macOS不一样。requirements.txt里一般会包含Django、数据库驱动、图片处理、白噪声静态文件工具这几类。如果安装psycopg2失败,通常是缺少编译工具链,换psycopg2-binary往往更省心。
依赖装好后,先不要急着迁移数据库,把settings里最容易出错的两个配置改掉:
export DJANGO_SECRET_KEY='your-random-secret-key' export DJANGO_DEBUG='True' export DJANGO_ALLOWED_HOSTS='localhost,127.0.0.1'对应的settings里需要配置为从环境变量读取,常见写法是使用python-dotenv或os.getenv。如果源码里SECRET_KEY是明文,替换成上述方式后记得不要把这个密钥提交到公共仓库。ALLOWED_HOSTS如果不加,访问时会抛出DisallowedHost异常,这是Django刻意做的安全保护,不是源码问题。
接下来执行数据库初始化和结构检查:
python manage.py makemigrations --dry-run python manage.py migrate--dry-run的作用是模拟生成迁移文件但不实际写入,用来检查模型和迁移文件之间是否有漂移。如果输出显示“No changes detected”,说明源码里的迁移文件和现在的模型一致;如果有变化,说明源码包可能改过模型但没生成新迁移。migrate执行完成后,会自动创建Django内置的auth、session、admin所需的数据表。
2.3 初始化后台账号并登录
Django-CRM没有内置一个密码写死的默认管理员,这是对的。最稳妥的方式是自己创建超级用户:
python manage.py createsuperuser python manage.py runserver 0.0.0.0:8000createsuperuser交互式提示输入用户名、Email和密码,密码要满足Django默认的强度校验,太简单会报错。启动后打开浏览器访问http://127.0.0.1:8000/admin,用刚创建的管理员账号登录。
登录进去后,第一件事不是到处点,而是先确认左侧菜单有没有把customer、followup、contract这几个核心模块都列出来。如果只有User和Group,说明对应的admin.py没有被加载,去看app目录底下的apps.py和settings的INSTALLED_APPS是否一致。如果源码自带演示数据或fixture文件,可在启动前执行python manage.py loaddata demo.json导入一些测试客户和跟进记录,这样调试的时候不至于对着空表空想。
3. 核心业务模块拆解:从客户线索到成交转化的数据链路
3.1 客户管理模块:字段设计与数据归属
客户表是整个CRM的地基,字段设计直接决定后续跟进和统计的写法。这类源码里客户模型通常长这样:
# customer/models.py from django.db import models from django.conf import settings class Customer(models.Model): STATUS_CHOICES = [ ('new', '新线索'), ('following', '跟进中'), ('contract', '已签约'), ('lost', '已流失'), ] name = models.CharField(max_length=100) phone = models.CharField(max_length=30, db_index=True) company = models.CharField(max_length=200, blank=True) source = models.CharField(max_length=50, choices=[ ('ads', '广告投放'), ('referral', '转介绍'), ('landing', '落地页'), ]) owner = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='customers' ) status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='new') created_at = models.DateTimeField(auto_now_add=True)我经常给身边做CRM的朋友强调两个细节。第一个是owner外键的on_delete=models.PROTECT,它比CASCADE更符合业务逻辑——如果一个销售账号删除了,客户数据不能跟着消失,PROTECT会让删除动作报错,逼你先把客户转移给别人。第二个是phone字段加db_index=True,因为客户列表和查重都按手机号精确匹配或模糊匹配,不加索引到几万条数据后会明显变慢。
后台注册部分要善用Django Admin的能力,不要全程裸用默认视图:
# customer/admin.py from django.contrib import admin from .models import Customer @admin.register(Customer) class CustomerAdmin(admin.ModelAdmin): list_display = ('name', 'phone', 'company', 'source', 'status', 'owner', 'created_at') list_filter = ('source', 'status', 'owner') search_fields = ('name', 'phone', 'company')list_filter和search_fields能帮运营侧极大提升效率,运行在后台列表页直接产生WHERE条件,不会把所有数据load进内存再过滤。这里有个常见误区:在search_fields里加外键关联字段时,如果不对应关系写全,Django会给出TypeError,正确的写法是owner__username这种跨关联路径。
3.2 跟进记录与销售漏斗:状态流转是怎么实现的
跟进记录是销售CRM里最能体现数据链路价值的部分。一笔客户从新线索到签约,中间可能产生多次电话、拜访和微信沟通,如果每次跟进互不关联,后期根本看不出销售动作是否有效。源码中通常会把跟进作为一条独立模型挂在客户下:
# followup/models.py class FollowUp(models.Model): customer = models.ForeignKey( Customer, on_delete=models.CASCADE, related_name='follow_ups' ) staff = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True ) content = models.TextField() next_contact_at = models.DateTimeField(null=True, blank=True) created_at = models.DateTimeField(auto_now_add=True) class Meta: ordering = ['-created_at']客户删除时跟进记录也应该消失,所以用CASCADE;员工离职时跟进记录里的content和created_at还需要保留,所以用SET_NULL,并允许staff为空。这个取舍是CRM里最常见的外键策略组合。
写查询时要注意N+1问题。如果循环客户列表再单独查每条客户的跟进历史,每页五十条客户就会产生五十多次SQL,听起来影响不大,但接上后台列表的加载会明显卡顿。正确写法是使用select_related一次性把关联对象带出来:
followups = FollowUp.objects.select_related('customer', 'staff').all()select_related适合外键这种单值关联,内部是在SQL里做JOIN。多对多或反向外键关系要用prefetch_related,思路是分两次查询后在Python侧拼装。如果发现后台查询非常慢,先打开DebugToolbar看一眼SQL数量,不要急着加缓存。
销售漏斗的统计一般用Case/When + Count完成,而不是把状态一个个写在Python循环里:
from django.db.models import Count, Case, When status_index = (Customer.objects .values('status') .annotate(total=Count('id')) .order_by('status'))这段倒不是非要在源码里找到,而是你自己改报表时一定会用。理解一个原则就好:聚合能下推到数据库的,别在Python侧做。
3.3 合同与回款:金额精度与状态机管理
合同表是销售链路从“意向”变“落单”的转折点。金额字段是我最关心的地方,合理的源码一定不会用FloatField:
# contract/models.py class Contract(models.Model): STATUS_CHOICES = [ ('draft', '草稿'), ('pending', '待审批'), ('active', '已生效'), ('finished', '已完成'), ('void', '已作废'), ] customer = models.ForeignKey(Customer, on_delete=models.PROTECT) amount = models.DecimalField(max_digits=12, decimal_places=2) status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='draft') signed_at = models.DateField(null=True, blank=True) approval_note = models.TextField(blank=True)DecimalField会比FloatField更稳妥,因为金融金额计算不能接受浮点数误差。max_digits=12表示最大十位整数和两位小数,议价空间通常在千万级以下都够用;如果涉及东南亚或不同币种,保留4位小数需要自行调整。
状态流转比普通页面里的下拉选择要严格得多。不能把status字段暴露给用户任意修改,否则会出现“草稿直接变已作废”这种逻辑漏洞。常见做法是在模型里定义状态转换方法:
def transition_status(self, new_status, operator): allowed_map = { 'draft': {'pending'}, 'pending': {'active', 'void'}, 'active': {'finished', 'void'}, } if new_status not in allowed_map.get(self.status, set()): raise ValueError(f'不允许从 {self.status} 转换到 {new_status}') self.status = new_status self.save()这样就把状态机的约束收拢在一处。审批动作通常放在Admin的action里,比如“提交审批”“审批通过”,每个action调用上面的方法。operator参数可以用来记录审批人和审批时间,给后续报表留证据链。
3.4 仪表盘与聚合统计:报表页面不是用手写循环算出来的
CRM系统如果没有报表模块,经理就看不到团队业绩和跟进情况。仪表盘里最常用的是按月订单总额、按渠道客户数、按销售员签约量这几类。Django的ORM提供了Trunc函数做时间分组:
from django.db.models import Sum, Count from django.db.models.functions import TruncMonth monthly_contract = ( Contract.objects .filter(status='active') .annotate(month=TruncMonth('signed_at')) .values('month') .annotate(total_amount=Sum('amount'), contract_count=Count('id')) .order_by('month') )TruncMonth会将每个signed_at截断到月份起点,再按月份分组,生成的SQL是GROUP BY DATE_TRUNC('month', signed_at),数据量小时完全足够。如果数据量上百万行,再做这种实时聚合会拖垮数据库,这时常见做法是每天凌晨用定时任务把聚合结果写进一张stats_summary表,页面只查汇总表。不要在报表模块里一上来就引入Redis缓存,先看慢查询落在哪。
仪表盘里还要注意一个“隐形坑”:把Sum出来的DecimalField在Python侧转float再格式化。我见过有人为了好拼接,写float(row['total_amount']),结果大金额出现精度偏移,展示给老板看时少了几分钱。正确做法是保留Decimal,用{:.2f}格式化输出,或者直接用模板里的floatformat过滤器。
4. 避坑手册:Django-CRM部署与开发中的五个拦路虎
4.1 SQLite能跑通的代码,MySQL迁移时报数值截断
现象:本地开发用SQLite一切正常,代码推到生产用MySQL,执行migrate或插入数据时直接抛Truncated incorrect DECIMAL value之类的异常。
原因:SQLite类型亲和性很宽松,字符串进数值字段时它不会立刻报错;MySQL则严格得多。最常见原因是模型里DecimalField和IntegerField没有设置默认值,而某个表单或脚本往里写空字符串。在SQLite里空字符串被当作0,在MySQL里直接变成语法警告或报错。
解决:如果字段语义允许为空,把字段改成null=True, blank=True, default=0,然后生成迁移:
python manage.py makemigrations contract python manage.py migrate如果是历史脏数据,需要先写一段脚本清洗,把空字符串更新为NULL或0,再跑迁移。
4.2 登录后台后CSS全部丢失,页面裸奔
现象:本地runserver调试时后台是完整的,部署到nginx后用域名访问,登录页和列表页全部没有样式,只剩干巴巴的HTML。
原因:Django在DEBUG=False时不再通过runserver自动提供静态文件。如果settings里没有配置静态文件收集路径和中间件,admin自带CSS、JS、图片都找不到。
解决:在INSTALLED_APPS里加入whitenoise,中间件里加上whitenoise.middleware.WhiteNoiseMiddleware,然后执行收集:
python manage.py collectstatic确保STATIC_ROOT指向一个nginx可用的本地目录。如果还是缺样式,需要检查nginx的location块是否把/static/映射到了STATIC_ROOT。
4.3 多人开发时迁移文件冲突导致项目慌
现象:A同学和B同学同时各加了一个新字段,各自生成迁移文件后准备合并时,执行makemigrations指向失败了,报告里出现一串Leaf node或MigrationGraphError。
原因:两个迁移文件基于同一历史版本生成了两条分叉,Django需要你来解决合并规则,而不是像git那样自动merge。
解决:千万不要删别人的迁移文件再重新生成,那样会破坏其他人的迁移链。正确手段是让其中一个分支等待或主动执行:
python manage.py makemigrations --merge合并生成的迁移文件会继承两个分支,执行后两条链路合到一起。这个操作对逻辑冲突无能为力,所以最终还要走一遍migrate,然后再做一次makemigrations --dry-run确认没有漂移。
4.4 跟进时间比实际晚了8小时
现象:创建的客户和跟进记录在后台显示创建时间是UTC时间,总是比电脑上的北京时间少8小时,月度报表也因此被切到错误的日期边界。
原因:Django默认USE_TZ=True,如果settings里没有把TIME_ZONE设置为业务时区,时间存进数据库是UTC,前端渲染时又没做时区转换,展示出来就是UTC值。
解决:在settings里设定:
TIME_ZONE = 'Asia/Shanghai' USE_TZ = True代码中不要用datetime.now(),统一使用django.utils.timezone.now()。数据库里存的仍然是UTC,但Django在写入和读取时会根据当前时区做转换。要注意已有代码里如果有硬编码的datetime.now(),必须全部替换,否则新旧记录会出现一个多小时偏移。
4.5 普通用户登录后能看到所有客户的私密信息
现象:已经给用户分配了“销售”角色,登录后台发现客户列表还是全量显示,根本不分归属人。
原因:Admin自带权限只控制“能不能进入该模型的管理页”,不控制“能看到哪些行”。如果没有重写get_queryset,默认所有有权限的用户都能看到整张表。
解决:在CustomerAdmin里重写查询集方法:
def get_queryset(self, request): qs = super().get_queryset(request) if request.user.is_superuser: return qs return qs.filter(owner=request.user)这样普通销售进入客户列表时,SQL自动带上owner=当前用户的条件。要注意list_filter、search_fields、下拉外键仍然引用全部数据,如果要做完整行级隔离,还需要重写formfield_for_foreignkey,把外键下拉也过滤到当前用户的数据集内。
5. 二次开发:扩展用户档案、字段与只读报表API
5.1 扩展User档案:用Profile模型而不是修改内置User
如果你在部署几个月后想给每个员工增加“所属门店”或“入职日期”字段,这时最稳妥的路径不是把AUTH_USER_MODEL改掉,因为系统已经迁移完成,临时换模型会带来繁琐的数据迁移。常见做法是新建一个Profile模型,用一对一关联内置User:
# accounts/models.py from django.conf import settings from django.db import models class Profile(models.Model): user = models.OneToOneField( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='profile' ) department = models.CharField(max_length=100, blank=True) employee_no = models.CharField(max_length=20, blank=True) created_at = models.DateTimeField(auto_now_add=True)然后通过信号自动创建Profile实例,避免每次手动去后台添加:
# accounts/signals.py from django.db.models.signals import post_save from django.contrib.auth.models import User from django.dispatch import receiver from .models import Profile @receiver(post_save, sender=User) def create_user_profile(sender, instance, created, **kwargs): if created: Profile.objects.create(user=instance)信号模块记得在AppConfig的ready()里import,否则不会生效。开发中如果发现创建用户后Profile没同步,第一反应不是去看信号逻辑,而是检查app的apps.py有没有把signals模块加载进来。
5.2 给客户表加自定义字段:迁移与后台注册
CRM上线后运营总想要更细的客户分组维度,比如“渠道活动ID”和“客户等级”。在Django里加字段是顺手的事,但有几个操作顺序不要搞反。
先在Customer模型里加字段:
channel_campaign = models.CharField(max_length=100, blank=True, verbose_name='渠道活动') grade = models.CharField( max_length=10, choices=[('A', 'A级客户'), ('B', 'B级客户'), ('C', 'C级客户')], default='C' )然后生成迁移:
python manage.py makemigrations customer python manage.py migrate customer迁移文件会自动生成,如果字段配置了choices,Admin后台的下拉选项也需要同步更新。在CustomerAdmin里调整:
list_display = ('name', 'phone', 'grade', 'channel_campaign') list_filter = ('grade', 'source')这里值得注意的是,迁移的同时不要忘了给存量数据填充默认值。如果加了null=False的字段,迁移时Django会询问你输入一个一次性默认值,这时如果不想用命令行交互,就预先在迁移文件里用RunPython写一段backfill逻辑。
5.3 用DRF做一个只读报表API
有些场景下CRM要和公司内部数据大屏对接,如果直接把Admin后台的HTML页面给大屏去爬,既恶心又脆弱。更合适的方式是使用Django REST framework提供几个只读接口。前提是源码的requirements里已有djangorestframework,没有则先安装。
在dashboard里新建序列化和视图:
# dashboard/serializers.py from rest_framework import serializers from contract.models import Contract class ContractReportSerializer(serializers.ModelSerializer): customer_name = serializers.CharField(source='customer.name') staff_name = serializers.CharField(source='customer.owner.username') class Meta: model = Contract fields = ('id', 'customer_name', 'staff_name', 'amount', 'signed_at')# dashboard/views.py from rest_framework.viewsets import ReadOnlyModelViewSet from contract.models import Contract from .serializers import ContractReportSerializer from rest_framework.permissions import IsAuthenticated class ContractReportViewSet(ReadOnlyModelViewSet): queryset = Contract.objects.filter(status='active') serializer_class = ContractReportSerializer permission_classes = [IsAuthenticated]最后在dashboard/urls.py里用router注册:
from rest_framework.routers import DefaultRouter router = DefaultRouter() router.register('contract-report', ContractReportViewSet, basename='contract-report')这样访问/api/contract-report/能拿到一份JSON格式的合同列表。ReadOnlyModelViewSet天然只支持GET,POST这类写操作不会被暴露,减少了改数据的入口。整个二次开发阶段最关键的一点是:不要把所有模型都交给同一个ViewSet不加过滤地暴露出错。你至少要在queryset上按当前用户所属团队过滤一遍,否则报表接口就成了新的数据泄漏口。
6. 再往前一步:后台一键导出客户数据并附操作留痕
把数据导成Excel或CSV给运营做线下分析,几乎是每个CRM项目的刚需。很多团队选择在页面里写一个自定义视图,手动遍历数据拼文件,但这样每次都要写大量样板代码。更省事的做法是利用Django Admin的action机制,直接在列表页勾选记录后触发导出。
# customer/admin.py import csv from django.http import HttpResponse def export_customers_csv(modeladmin, request, queryset): response = HttpResponse(content_type='text/csv; charset=utf-8') response['Content-Disposition'] = 'attachment; filename="customers.csv"' writer = csv.writer(response) writer.writerow(['姓名', '电话', '公司', '状态', '负责人']) for customer in queryset: writer.writerow([ customer.name, customer.phone, customer.company, customer.get_status_display(), customer.owner.username ]) return response export_customers_csv.short_description = '导出选中客户为CSV'然后把action注册到CustomerAdmin里:
actions = [export_customers_csv]这个action触发后,浏览器会直接下载一个customers.csv,并且只包含勾选的记录。注意get_status_display()会返回状态对应的中文显示名,不要直接写customer.status,否则导出的文件里全是英文代码。如果需要导出为Excel.xlsx而不是CSV,可以引入openpyxl或pandas,但CSV的好处是纯日常环境就能打开,不额外依赖。
如果你担心运营导出数据后无法追溯操作,可以把action改成先写日志再返回:
from django.contrib import messages def export_customers_csv_with_log(modeladmin, request, queryset): export_customers_csv(modeladmin, request, queryset) from .models import ExportLog ExportLog.objects.create( operator=request.user, count=queryset.count() ) messages.success(request, f'已导出 {queryset.count()} 条客户记录')这样每次点击导出都会在数据库里留下一条操作记录,对销售敏感数据来说是很必要的审计习惯。从那以后,我每次拿到别人的Django-CRM源码,都会先检查那块看不见的数据导出实验,甚至会在部署前重新梳理一遍后台的action列表,确保不该被公开的操作没有默认开启。希望帮到你。
本文还有配套的精品资源,点击获取